azfive-capture 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.
- azfive_capture-0.1.0/.gitignore +69 -0
- azfive_capture-0.1.0/LICENSE +21 -0
- azfive_capture-0.1.0/PKG-INFO +62 -0
- azfive_capture-0.1.0/README.md +44 -0
- azfive_capture-0.1.0/pyproject.toml +30 -0
- azfive_capture-0.1.0/src/azfive_capture/__init__.py +11 -0
- azfive_capture-0.1.0/src/azfive_capture/client.py +324 -0
- azfive_capture-0.1.0/src/azfive_capture/flags.py +132 -0
- azfive_capture-0.1.0/src/azfive_capture/transport.py +78 -0
- azfive_capture-0.1.0/tests/conftest.py +104 -0
- azfive_capture-0.1.0/tests/test_batching.py +98 -0
- azfive_capture-0.1.0/tests/test_envelope.py +104 -0
- azfive_capture-0.1.0/tests/test_flags.py +135 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
venv/
|
|
7
|
+
*.egg
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
htmlcov/
|
|
13
|
+
|
|
14
|
+
# Node
|
|
15
|
+
node_modules/
|
|
16
|
+
dist/
|
|
17
|
+
|
|
18
|
+
# Environment
|
|
19
|
+
.env
|
|
20
|
+
.env.local
|
|
21
|
+
.env.*.local
|
|
22
|
+
|
|
23
|
+
# IDE
|
|
24
|
+
.vscode/
|
|
25
|
+
.idea/
|
|
26
|
+
*.swp
|
|
27
|
+
*.swo
|
|
28
|
+
|
|
29
|
+
# OS
|
|
30
|
+
.DS_Store
|
|
31
|
+
Thumbs.db
|
|
32
|
+
|
|
33
|
+
# Data
|
|
34
|
+
*.parquet
|
|
35
|
+
*.csv
|
|
36
|
+
*.db
|
|
37
|
+
|
|
38
|
+
# Rust
|
|
39
|
+
**/target/
|
|
40
|
+
|
|
41
|
+
# AI coding-tool state (local per-developer config/session data)
|
|
42
|
+
.claude/
|
|
43
|
+
.agents/
|
|
44
|
+
.codex/
|
|
45
|
+
|
|
46
|
+
# Impeccable (cache/session data — design.json is derived from DESIGN.md)
|
|
47
|
+
.impeccable/
|
|
48
|
+
frontend/src/.impeccable/
|
|
49
|
+
|
|
50
|
+
# Playwright
|
|
51
|
+
playwright-report/
|
|
52
|
+
test-results/
|
|
53
|
+
|
|
54
|
+
# DB backups
|
|
55
|
+
azfive-backup-*.sql
|
|
56
|
+
|
|
57
|
+
# Built frontend (produced by the Docker build's `npm run build`). Never commit
|
|
58
|
+
# it: stale committed chunks get served alongside fresh ones and can pin an old
|
|
59
|
+
# bundle that lacks the X-AzFive-Branch header, silently routing branch writes to main.
|
|
60
|
+
backend/static/
|
|
61
|
+
|
|
62
|
+
# SDK build outputs (sdk-ios SwiftPM, Flutter, Android/Gradle)
|
|
63
|
+
sdk-ios/.build/
|
|
64
|
+
sdk-flutter/build/
|
|
65
|
+
sdk-flutter/.dart_tool/
|
|
66
|
+
sdk-android/.gradle/
|
|
67
|
+
sdk-android/**/build/
|
|
68
|
+
sdk-jvm/.gradle/
|
|
69
|
+
sdk-jvm/build/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AzFive
|
|
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.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: azfive-capture
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Capture product-analytics events into AzFive from Python servers — events, identity, feature flags & experiments.
|
|
5
|
+
Author: AzFive
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# azfive-capture (Python)
|
|
20
|
+
|
|
21
|
+
AzFive product analytics for Python servers — events, identity, super
|
|
22
|
+
properties, and feature flags/experiments. Zero dependencies (stdlib only).
|
|
23
|
+
See `sdk-spec/SPEC.md` in the AzFive repo for the cross-SDK contract.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install azfive-capture
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Use a **secret** API key with the `events:write` scope (AzFive → Settings → API
|
|
32
|
+
Keys). Secret keys are also valid on `/v1/decide`, so server-side flag
|
|
33
|
+
evaluation needs no extra setup.
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from azfive_capture import Client
|
|
39
|
+
|
|
40
|
+
azfive = Client("azfive_…", host="https://analytics.example.com", project="backend")
|
|
41
|
+
|
|
42
|
+
azfive.capture("invoice_paid", {"amount": 99}, distinct_id="user-42")
|
|
43
|
+
azfive.people.set({"plan": "pro"}, distinct_id="user-42")
|
|
44
|
+
|
|
45
|
+
# experiments / feature flags — get_flag() reports the exposure automatically,
|
|
46
|
+
# deduped per (distinct_id, flag, value)
|
|
47
|
+
variant = azfive.get_flag("user-42", "exp-checkout") # 'control' | 'test' | False | None
|
|
48
|
+
if azfive.is_flag_enabled("user-42", "new-onboarding"):
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
azfive.flush() # also flushed by the background thread and at exit
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Batching: events queue in memory (batch 10 / 5 s / max 1000, oldest dropped)
|
|
55
|
+
and are delivered by a daemon thread; failed batches retry on the next tick.
|
|
56
|
+
`close()` (also registered via `atexit`) drains the queue.
|
|
57
|
+
|
|
58
|
+
## Tests
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install -e '.[dev]' && pytest # golden vectors from ../sdk-spec/vectors
|
|
62
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# azfive-capture (Python)
|
|
2
|
+
|
|
3
|
+
AzFive product analytics for Python servers — events, identity, super
|
|
4
|
+
properties, and feature flags/experiments. Zero dependencies (stdlib only).
|
|
5
|
+
See `sdk-spec/SPEC.md` in the AzFive repo for the cross-SDK contract.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install azfive-capture
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Use a **secret** API key with the `events:write` scope (AzFive → Settings → API
|
|
14
|
+
Keys). Secret keys are also valid on `/v1/decide`, so server-side flag
|
|
15
|
+
evaluation needs no extra setup.
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from azfive_capture import Client
|
|
21
|
+
|
|
22
|
+
azfive = Client("azfive_…", host="https://analytics.example.com", project="backend")
|
|
23
|
+
|
|
24
|
+
azfive.capture("invoice_paid", {"amount": 99}, distinct_id="user-42")
|
|
25
|
+
azfive.people.set({"plan": "pro"}, distinct_id="user-42")
|
|
26
|
+
|
|
27
|
+
# experiments / feature flags — get_flag() reports the exposure automatically,
|
|
28
|
+
# deduped per (distinct_id, flag, value)
|
|
29
|
+
variant = azfive.get_flag("user-42", "exp-checkout") # 'control' | 'test' | False | None
|
|
30
|
+
if azfive.is_flag_enabled("user-42", "new-onboarding"):
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
azfive.flush() # also flushed by the background thread and at exit
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Batching: events queue in memory (batch 10 / 5 s / max 1000, oldest dropped)
|
|
37
|
+
and are delivered by a daemon thread; failed batches retry on the next tick.
|
|
38
|
+
`close()` (also registered via `atexit`) drains the queue.
|
|
39
|
+
|
|
40
|
+
## Tests
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e '.[dev]' && pytest # golden vectors from ../sdk-spec/vectors
|
|
44
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "azfive-capture"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Capture product-analytics events into AzFive from Python servers — events, identity, feature flags & experiments."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "AzFive" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=8"]
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/azfive_capture"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""azfive-capture — AzFive product analytics for Python servers.
|
|
2
|
+
|
|
3
|
+
Events, identity, super properties and feature flags/experiments against a
|
|
4
|
+
AzFive deployment. See ``sdk-spec/SPEC.md`` in the AzFive repo for the
|
|
5
|
+
cross-SDK behavioral contract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import LIB_NAME, LIB_VERSION, Client, create_client
|
|
9
|
+
from .flags import FlagValue
|
|
10
|
+
|
|
11
|
+
__all__ = ["Client", "create_client", "FlagValue", "LIB_NAME", "LIB_VERSION"]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""AzFive capture client for Python servers.
|
|
2
|
+
|
|
3
|
+
Port of ``@azfive/capture`` (see ``sdk-spec/SPEC.md``): same event envelope,
|
|
4
|
+
merge order, batching semantics and flag exposure rules, adapted to a server
|
|
5
|
+
runtime — in-memory identity, no session, background flush thread, and flag
|
|
6
|
+
evaluation per ``distinct_id`` through ``/v1/decide`` with a secret key.
|
|
7
|
+
|
|
8
|
+
from azfive_capture import Client
|
|
9
|
+
|
|
10
|
+
azfive = Client("azfive_…", host="https://analytics.example.com")
|
|
11
|
+
azfive.capture("invoice_paid", {"amount": 99}, distinct_id="user-42")
|
|
12
|
+
variant = azfive.get_flag("user-42", "exp-checkout")
|
|
13
|
+
azfive.flush()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import atexit
|
|
19
|
+
import platform
|
|
20
|
+
import threading
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from . import transport
|
|
25
|
+
from .flags import FlagsManager, FlagValue
|
|
26
|
+
|
|
27
|
+
LIB_VERSION = "0.1.0"
|
|
28
|
+
LIB_NAME = "azfive-python"
|
|
29
|
+
|
|
30
|
+
DEFAULT_BATCH_SIZE = 10
|
|
31
|
+
DEFAULT_FLUSH_INTERVAL_S = 5.0
|
|
32
|
+
DEFAULT_MAX_QUEUE_SIZE = 1000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _now_iso() -> str:
|
|
36
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _static_props() -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"$os": platform.system() or "Unknown",
|
|
42
|
+
"$os_version": platform.release() or "",
|
|
43
|
+
"$device_type": "Server",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def merge_properties(
|
|
48
|
+
static: dict, session: dict, super_props: dict, event: dict, reserved: dict
|
|
49
|
+
) -> dict:
|
|
50
|
+
"""The spec'd merge order: static < session < super < event < reserved.
|
|
51
|
+
(Server SDKs have no dynamic layer; session is empty for them.)"""
|
|
52
|
+
return {**static, **session, **super_props, **event, **reserved}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class People:
|
|
56
|
+
"""``people.set`` / ``people.set_once`` — person-property events."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, client: "Client") -> None:
|
|
59
|
+
self._client = client
|
|
60
|
+
|
|
61
|
+
def set(self, properties: dict, distinct_id: str | None = None) -> None:
|
|
62
|
+
self._client._track("az.set", {}, set_props=properties, distinct_id=distinct_id)
|
|
63
|
+
|
|
64
|
+
def set_once(self, properties: dict, distinct_id: str | None = None) -> None:
|
|
65
|
+
self._client._track("az.set_once", {}, set_once_props=properties, distinct_id=distinct_id)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Client:
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
api_key: str,
|
|
72
|
+
*,
|
|
73
|
+
host: str,
|
|
74
|
+
project: str = "default",
|
|
75
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
76
|
+
flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S,
|
|
77
|
+
max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE,
|
|
78
|
+
gzip: bool = False,
|
|
79
|
+
opt_out: bool = False,
|
|
80
|
+
disable_decide: bool = False,
|
|
81
|
+
extra_static_props: dict | None = None,
|
|
82
|
+
start_flush_thread: bool = True,
|
|
83
|
+
) -> None:
|
|
84
|
+
if not api_key:
|
|
85
|
+
raise ValueError("api_key is required")
|
|
86
|
+
if not host:
|
|
87
|
+
raise ValueError("host is required")
|
|
88
|
+
self._api_key = api_key
|
|
89
|
+
self._host = host.rstrip("/")
|
|
90
|
+
self._project = project
|
|
91
|
+
self._batch_size = batch_size
|
|
92
|
+
self._flush_interval_s = flush_interval_s
|
|
93
|
+
self._max_queue_size = max_queue_size
|
|
94
|
+
self._gzip = gzip
|
|
95
|
+
self._opted_out = opt_out
|
|
96
|
+
|
|
97
|
+
self._distinct_id = transport.uuid4()
|
|
98
|
+
self._device_id = transport.uuid4()
|
|
99
|
+
self._identified = False
|
|
100
|
+
self._super_props: dict = {}
|
|
101
|
+
self._static = {**_static_props(), **(extra_static_props or {})}
|
|
102
|
+
|
|
103
|
+
self._queue: list[dict] = []
|
|
104
|
+
self._lock = threading.Lock()
|
|
105
|
+
self._closed = False
|
|
106
|
+
|
|
107
|
+
self.people = People(self)
|
|
108
|
+
self._flags: FlagsManager | None = None
|
|
109
|
+
if not disable_decide:
|
|
110
|
+
self._flags = FlagsManager(
|
|
111
|
+
host=self._host,
|
|
112
|
+
api_key=api_key,
|
|
113
|
+
project=project,
|
|
114
|
+
capture=self._capture_for_flags,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
self._flush_event = threading.Event()
|
|
118
|
+
self._thread: threading.Thread | None = None
|
|
119
|
+
if start_flush_thread:
|
|
120
|
+
self._thread = threading.Thread(
|
|
121
|
+
target=self._flush_loop, name="azfive-capture-flush", daemon=True
|
|
122
|
+
)
|
|
123
|
+
self._thread.start()
|
|
124
|
+
atexit.register(self.close)
|
|
125
|
+
|
|
126
|
+
# ── Identity ──────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def get_distinct_id(self) -> str:
|
|
129
|
+
return self._distinct_id
|
|
130
|
+
|
|
131
|
+
def get_session_id(self) -> str:
|
|
132
|
+
return "" # servers have no session
|
|
133
|
+
|
|
134
|
+
def identify(
|
|
135
|
+
self, distinct_id: str, set: dict | None = None, set_once: dict | None = None
|
|
136
|
+
) -> None:
|
|
137
|
+
did = str(distinct_id)
|
|
138
|
+
if self._identified and self._distinct_id == did:
|
|
139
|
+
if set or set_once:
|
|
140
|
+
self._track("az.set", {}, set_props=set, set_once_props=set_once)
|
|
141
|
+
return
|
|
142
|
+
prev = self._distinct_id
|
|
143
|
+
self._distinct_id = did
|
|
144
|
+
self._identified = True
|
|
145
|
+
self._track(
|
|
146
|
+
"az.identify",
|
|
147
|
+
{"$anon_distinct_id": prev},
|
|
148
|
+
set_props=set,
|
|
149
|
+
set_once_props=set_once,
|
|
150
|
+
distinct_id=did,
|
|
151
|
+
)
|
|
152
|
+
if self._flags:
|
|
153
|
+
self._flags.reload_flags(did)
|
|
154
|
+
|
|
155
|
+
def alias(self, alias: str) -> None:
|
|
156
|
+
self._track("az.alias", {"alias": str(alias)})
|
|
157
|
+
|
|
158
|
+
def reset(self, reset_device_id: bool = False) -> None:
|
|
159
|
+
self._identified = False
|
|
160
|
+
self._distinct_id = transport.uuid4()
|
|
161
|
+
self._super_props = {}
|
|
162
|
+
if reset_device_id:
|
|
163
|
+
self._device_id = transport.uuid4()
|
|
164
|
+
# opt-out is intentionally preserved across reset()
|
|
165
|
+
|
|
166
|
+
# ── Super properties ──────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def register(self, props: dict) -> None:
|
|
169
|
+
self._super_props = {**self._super_props, **props}
|
|
170
|
+
|
|
171
|
+
def register_once(self, props: dict) -> None:
|
|
172
|
+
self._super_props = {**props, **self._super_props} # existing values win
|
|
173
|
+
|
|
174
|
+
def unregister(self, key: str) -> None:
|
|
175
|
+
self._super_props.pop(key, None)
|
|
176
|
+
|
|
177
|
+
# ── Opt-out ───────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
def opt_in_capturing(self) -> None:
|
|
180
|
+
self._opted_out = False
|
|
181
|
+
|
|
182
|
+
def opt_out_capturing(self) -> None:
|
|
183
|
+
self._opted_out = True
|
|
184
|
+
|
|
185
|
+
def has_opted_out_capturing(self) -> bool:
|
|
186
|
+
return self._opted_out
|
|
187
|
+
|
|
188
|
+
# ── Capture ───────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def capture(
|
|
191
|
+
self, event: str, properties: dict | None = None, *, distinct_id: str | None = None
|
|
192
|
+
) -> None:
|
|
193
|
+
self._track(event, properties or {}, distinct_id=distinct_id)
|
|
194
|
+
|
|
195
|
+
def _capture_for_flags(
|
|
196
|
+
self, event: str, properties: dict, *, distinct_id: str
|
|
197
|
+
) -> None:
|
|
198
|
+
self._track(event, properties, distinct_id=distinct_id)
|
|
199
|
+
|
|
200
|
+
def _track(
|
|
201
|
+
self,
|
|
202
|
+
event: str,
|
|
203
|
+
properties: dict,
|
|
204
|
+
*,
|
|
205
|
+
set_props: dict | None = None,
|
|
206
|
+
set_once_props: dict | None = None,
|
|
207
|
+
distinct_id: str | None = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
if self._opted_out:
|
|
210
|
+
return
|
|
211
|
+
props = dict(properties)
|
|
212
|
+
did = distinct_id or self._distinct_id
|
|
213
|
+
if props.get("distinct_id") is not None:
|
|
214
|
+
did = str(props.pop("distinct_id"))
|
|
215
|
+
evt: dict = {
|
|
216
|
+
"event": event,
|
|
217
|
+
"distinct_id": did,
|
|
218
|
+
"timestamp": _now_iso(),
|
|
219
|
+
"uuid": transport.uuid4(),
|
|
220
|
+
"properties": merge_properties(
|
|
221
|
+
self._static,
|
|
222
|
+
{},
|
|
223
|
+
self._super_props,
|
|
224
|
+
props,
|
|
225
|
+
{"$lib": LIB_NAME, "$lib_version": LIB_VERSION, "$device_id": self._device_id},
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
if set_props:
|
|
229
|
+
evt["$set"] = set_props
|
|
230
|
+
if set_once_props:
|
|
231
|
+
evt["$set_once"] = set_once_props
|
|
232
|
+
self._enqueue(evt)
|
|
233
|
+
|
|
234
|
+
def _enqueue(self, evt: dict) -> None:
|
|
235
|
+
with self._lock:
|
|
236
|
+
self._queue.append(evt)
|
|
237
|
+
if len(self._queue) > self._max_queue_size:
|
|
238
|
+
del self._queue[: len(self._queue) - self._max_queue_size]
|
|
239
|
+
trigger = len(self._queue) >= self._batch_size
|
|
240
|
+
if trigger:
|
|
241
|
+
self._request_flush()
|
|
242
|
+
|
|
243
|
+
def _request_flush(self) -> None:
|
|
244
|
+
# With the background thread running, hand off (capture() never blocks
|
|
245
|
+
# on network I/O); without it (tests, single-shot scripts) flush inline.
|
|
246
|
+
if self._thread is not None:
|
|
247
|
+
self._flush_event.set()
|
|
248
|
+
else:
|
|
249
|
+
self.flush()
|
|
250
|
+
|
|
251
|
+
# ── Feature flags ─────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
def get_flag(
|
|
254
|
+
self, distinct_id: str, key: str, person_properties: dict | None = None
|
|
255
|
+
) -> FlagValue | None:
|
|
256
|
+
if self._flags is None:
|
|
257
|
+
return None
|
|
258
|
+
return self._flags.get_flag(distinct_id, key, person_properties)
|
|
259
|
+
|
|
260
|
+
def is_flag_enabled(
|
|
261
|
+
self, distinct_id: str, key: str, person_properties: dict | None = None
|
|
262
|
+
) -> bool:
|
|
263
|
+
if self._flags is None:
|
|
264
|
+
return False
|
|
265
|
+
return self._flags.is_flag_enabled(distinct_id, key, person_properties)
|
|
266
|
+
|
|
267
|
+
def get_flag_payload(
|
|
268
|
+
self, distinct_id: str, key: str, person_properties: dict | None = None
|
|
269
|
+
) -> Any:
|
|
270
|
+
if self._flags is None:
|
|
271
|
+
return None
|
|
272
|
+
return self._flags.get_flag_payload(distinct_id, key, person_properties)
|
|
273
|
+
|
|
274
|
+
def reload_flags(self, distinct_id: str) -> None:
|
|
275
|
+
if self._flags is not None:
|
|
276
|
+
self._flags.reload_flags(distinct_id)
|
|
277
|
+
|
|
278
|
+
# ── Delivery ──────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
def _send(self, batch: list[dict]) -> bool:
|
|
281
|
+
return transport.send_batch(
|
|
282
|
+
batch,
|
|
283
|
+
host=self._host,
|
|
284
|
+
api_key=self._api_key,
|
|
285
|
+
project=self._project,
|
|
286
|
+
gzip=self._gzip,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def flush(self) -> None:
|
|
290
|
+
with self._lock:
|
|
291
|
+
if not self._queue:
|
|
292
|
+
return
|
|
293
|
+
batch = self._queue
|
|
294
|
+
self._queue = []
|
|
295
|
+
if not self._send(batch):
|
|
296
|
+
with self._lock:
|
|
297
|
+
# Re-queue at the FRONT, bounded keeping the newest events.
|
|
298
|
+
self._queue = (batch + self._queue)[-self._max_queue_size:]
|
|
299
|
+
|
|
300
|
+
def _flush_loop(self) -> None:
|
|
301
|
+
while not self._closed:
|
|
302
|
+
self._flush_event.wait(timeout=self._flush_interval_s)
|
|
303
|
+
self._flush_event.clear()
|
|
304
|
+
if self._closed:
|
|
305
|
+
return
|
|
306
|
+
try:
|
|
307
|
+
self.flush()
|
|
308
|
+
except Exception: # never kill the flush thread
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
def close(self) -> None:
|
|
312
|
+
"""Flush pending events and stop the background thread. Idempotent."""
|
|
313
|
+
if self._closed:
|
|
314
|
+
return
|
|
315
|
+
self._closed = True
|
|
316
|
+
self._flush_event.set()
|
|
317
|
+
try:
|
|
318
|
+
self.flush()
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def create_client(api_key: str, **config: Any) -> Client:
|
|
324
|
+
return Client(api_key, **config)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Server-side feature flags: /v1/decide per distinct_id with a TTL cache and
|
|
2
|
+
automatic, deduped ``az.flag_called`` exposure events.
|
|
3
|
+
|
|
4
|
+
Servers have no session, so exposures dedup on ``(distinct_id, flag, value)``
|
|
5
|
+
in a bounded LRU — the experiment pipeline only counts a person's FIRST
|
|
6
|
+
exposure anyway, so the LRU merely caps event noise, not correctness.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
from . import transport
|
|
17
|
+
|
|
18
|
+
FlagValue = bool | str
|
|
19
|
+
|
|
20
|
+
DECIDE_CACHE_TTL_S = 60.0
|
|
21
|
+
DECIDE_CACHE_MAX = 10_000
|
|
22
|
+
EXPOSURE_LRU_MAX = 50_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _LRU:
|
|
26
|
+
"""Tiny thread-safe bounded LRU used for decide caching + exposure dedup."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, max_entries: int) -> None:
|
|
29
|
+
self._data: OrderedDict[str, Any] = OrderedDict()
|
|
30
|
+
self._max = max_entries
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def get(self, key: str) -> Any | None:
|
|
34
|
+
with self._lock:
|
|
35
|
+
if key not in self._data:
|
|
36
|
+
return None
|
|
37
|
+
self._data.move_to_end(key)
|
|
38
|
+
return self._data[key]
|
|
39
|
+
|
|
40
|
+
def put(self, key: str, value: Any) -> None:
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._data[key] = value
|
|
43
|
+
self._data.move_to_end(key)
|
|
44
|
+
while len(self._data) > self._max:
|
|
45
|
+
self._data.popitem(last=False)
|
|
46
|
+
|
|
47
|
+
def contains(self, key: str) -> bool:
|
|
48
|
+
return self.get(key) is not None
|
|
49
|
+
|
|
50
|
+
def remove(self, key: str) -> None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._data.pop(key, None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FlagsManager:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
host: str,
|
|
60
|
+
api_key: str,
|
|
61
|
+
project: str,
|
|
62
|
+
capture: Callable[..., None],
|
|
63
|
+
cache_ttl_s: float = DECIDE_CACHE_TTL_S,
|
|
64
|
+
now: Callable[[], float] = time.monotonic,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._host = host
|
|
67
|
+
self._api_key = api_key
|
|
68
|
+
self._project = project
|
|
69
|
+
self._capture = capture
|
|
70
|
+
self._ttl = cache_ttl_s
|
|
71
|
+
self._now = now
|
|
72
|
+
self._decide_cache = _LRU(DECIDE_CACHE_MAX)
|
|
73
|
+
self._exposures = _LRU(EXPOSURE_LRU_MAX)
|
|
74
|
+
|
|
75
|
+
# ── decide ────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def _decide(self, distinct_id: str, person_properties: dict | None) -> dict:
|
|
78
|
+
# person_properties bypass the cache only when they change the request —
|
|
79
|
+
# cache key folds them in so overlays don't leak between calls.
|
|
80
|
+
cache_key = distinct_id if not person_properties else (
|
|
81
|
+
distinct_id + "|" + repr(sorted(person_properties.items()))
|
|
82
|
+
)
|
|
83
|
+
cached = self._decide_cache.get(cache_key)
|
|
84
|
+
if cached is not None and self._now() - cached["at"] < self._ttl:
|
|
85
|
+
return cached["doc"]
|
|
86
|
+
payload: dict = {"distinct_id": distinct_id, "project": self._project}
|
|
87
|
+
if person_properties:
|
|
88
|
+
payload["person_properties"] = person_properties
|
|
89
|
+
doc = transport.post_json(f"{self._host}/api/v1/decide", self._api_key, payload)
|
|
90
|
+
if doc is None:
|
|
91
|
+
# Network failure: serve stale if we have it, else empty. Never raise.
|
|
92
|
+
return cached["doc"] if cached is not None else {"flags": {}, "flagPayloads": {}}
|
|
93
|
+
self._decide_cache.put(cache_key, {"at": self._now(), "doc": doc})
|
|
94
|
+
return doc
|
|
95
|
+
|
|
96
|
+
def reload_flags(self, distinct_id: str) -> None:
|
|
97
|
+
"""Bust one distinct_id's cached decide response."""
|
|
98
|
+
self._decide_cache.remove(distinct_id)
|
|
99
|
+
|
|
100
|
+
# ── getters ───────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def get_flag(
|
|
103
|
+
self, distinct_id: str, key: str, person_properties: dict | None = None
|
|
104
|
+
) -> FlagValue | None:
|
|
105
|
+
doc = self._decide(distinct_id, person_properties)
|
|
106
|
+
value = (doc.get("flags") or {}).get(key)
|
|
107
|
+
self._report_exposure(distinct_id, key, value)
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
def is_flag_enabled(
|
|
111
|
+
self, distinct_id: str, key: str, person_properties: dict | None = None
|
|
112
|
+
) -> bool:
|
|
113
|
+
return bool(self.get_flag(distinct_id, key, person_properties))
|
|
114
|
+
|
|
115
|
+
def get_flag_payload(
|
|
116
|
+
self, distinct_id: str, key: str, person_properties: dict | None = None
|
|
117
|
+
) -> Any:
|
|
118
|
+
# No exposure — payload reads follow a get_flag/is_flag_enabled call.
|
|
119
|
+
doc = self._decide(distinct_id, person_properties)
|
|
120
|
+
return (doc.get("flagPayloads") or {}).get(key)
|
|
121
|
+
|
|
122
|
+
def _report_exposure(self, distinct_id: str, key: str, value: FlagValue | None) -> None:
|
|
123
|
+
reported = False if value is None else value
|
|
124
|
+
dedupe = f"{distinct_id}:{key}:{reported!r}"
|
|
125
|
+
if self._exposures.contains(dedupe):
|
|
126
|
+
return
|
|
127
|
+
self._exposures.put(dedupe, True)
|
|
128
|
+
self._capture(
|
|
129
|
+
"az.flag_called",
|
|
130
|
+
{"$feature_flag": key, "$feature_flag_response": reported},
|
|
131
|
+
distinct_id=distinct_id,
|
|
132
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""HTTP transport: batch POST with optional gzip, stdlib-only.
|
|
2
|
+
|
|
3
|
+
Endpoint selection mirrors the reference SDK: public tokens (``azfive_pub_…``)
|
|
4
|
+
use the browser-safe ingest path, anything else the server path. Delivery is
|
|
5
|
+
best-effort — the client re-queues failed batches; this module never raises.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import gzip as _gzip
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("azfive_capture")
|
|
18
|
+
|
|
19
|
+
_TIMEOUT_S = 10.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def uuid4() -> str:
|
|
23
|
+
return str(uuid.uuid4())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ingest_path(api_key: str) -> str:
|
|
27
|
+
return "/api/v1/events/ingest" if api_key.startswith("azfive_pub_") else "/api/v1/events"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def send_batch(
|
|
31
|
+
events: list[dict],
|
|
32
|
+
*,
|
|
33
|
+
host: str,
|
|
34
|
+
api_key: str,
|
|
35
|
+
project: str,
|
|
36
|
+
gzip: bool = False,
|
|
37
|
+
) -> bool:
|
|
38
|
+
"""POST one batch. Returns True when the server accepted the request."""
|
|
39
|
+
body = json.dumps({"events": events, "project": project}).encode()
|
|
40
|
+
headers = {
|
|
41
|
+
"Authorization": f"Bearer {api_key}",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
}
|
|
44
|
+
if gzip:
|
|
45
|
+
body = _gzip.compress(body)
|
|
46
|
+
headers["Content-Encoding"] = "gzip"
|
|
47
|
+
req = urllib.request.Request(
|
|
48
|
+
f"{host}{ingest_path(api_key)}", data=body, headers=headers, method="POST"
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as res:
|
|
52
|
+
return 200 <= res.status < 300
|
|
53
|
+
except urllib.error.HTTPError as exc:
|
|
54
|
+
logger.debug("azfive batch rejected: %s", exc.code)
|
|
55
|
+
return False
|
|
56
|
+
except Exception as exc: # network errors — silent, caller re-queues
|
|
57
|
+
logger.debug("azfive batch failed: %s", exc)
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def post_json(url: str, api_key: str, payload: dict) -> dict | None:
|
|
62
|
+
"""POST JSON, return the decoded JSON response or None on any failure."""
|
|
63
|
+
body = json.dumps(payload).encode()
|
|
64
|
+
req = urllib.request.Request(
|
|
65
|
+
url,
|
|
66
|
+
data=body,
|
|
67
|
+
headers={
|
|
68
|
+
"Authorization": f"Bearer {api_key}",
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
},
|
|
71
|
+
method="POST",
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as res:
|
|
75
|
+
return json.loads(res.read().decode())
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
logger.debug("azfive decide failed: %s", exc)
|
|
78
|
+
return None
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
10
|
+
|
|
11
|
+
VECTORS_DIR = Path(__file__).parent.parent.parent / "sdk-spec" / "vectors"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_vectors(name: str) -> dict:
|
|
15
|
+
return json.loads((VECTORS_DIR / f"{name}.json").read_text())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MockAzFive:
|
|
19
|
+
"""Tiny scriptable AzFive stub: records (path, headers, body) and replays
|
|
20
|
+
canned responses. ``fail_next`` forces a 500 on the next events POST."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.requests: list[dict] = []
|
|
24
|
+
self.decide_response: dict = load_vectors("decide-fixtures")["response_empty"]
|
|
25
|
+
self.fail_next = False
|
|
26
|
+
|
|
27
|
+
outer = self
|
|
28
|
+
|
|
29
|
+
class Handler(BaseHTTPRequestHandler):
|
|
30
|
+
def do_POST(self): # noqa: N802
|
|
31
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
32
|
+
raw = self.rfile.read(length)
|
|
33
|
+
if self.headers.get("Content-Encoding") == "gzip":
|
|
34
|
+
import gzip
|
|
35
|
+
raw = gzip.decompress(raw)
|
|
36
|
+
body = json.loads(raw) if raw else {}
|
|
37
|
+
outer.requests.append({
|
|
38
|
+
"path": self.path,
|
|
39
|
+
"headers": dict(self.headers),
|
|
40
|
+
"body": body,
|
|
41
|
+
})
|
|
42
|
+
if self.path.endswith("/decide"):
|
|
43
|
+
payload = outer.decide_response
|
|
44
|
+
elif outer.fail_next:
|
|
45
|
+
outer.fail_next = False
|
|
46
|
+
self.send_response(500)
|
|
47
|
+
self.end_headers()
|
|
48
|
+
return
|
|
49
|
+
else:
|
|
50
|
+
payload = {"accepted": len(body.get("events", [])), "rejected": 0}
|
|
51
|
+
data = json.dumps(payload).encode()
|
|
52
|
+
self.send_response(200)
|
|
53
|
+
self.send_header("Content-Type", "application/json")
|
|
54
|
+
self.send_header("Content-Length", str(len(data)))
|
|
55
|
+
self.end_headers()
|
|
56
|
+
self.wfile.write(data)
|
|
57
|
+
|
|
58
|
+
def log_message(self, *a): # silence
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
self._server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
62
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
63
|
+
self._thread.start()
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def host(self) -> str:
|
|
67
|
+
return f"http://127.0.0.1:{self._server.server_port}"
|
|
68
|
+
|
|
69
|
+
def events_batches(self) -> list[list[str]]:
|
|
70
|
+
return [
|
|
71
|
+
[e["event"] for e in r["body"].get("events", [])]
|
|
72
|
+
for r in self.requests
|
|
73
|
+
if "/events" in r["path"]
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
self._server.shutdown()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.fixture
|
|
81
|
+
def mock_server():
|
|
82
|
+
server = MockAzFive()
|
|
83
|
+
yield server
|
|
84
|
+
server.close()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.fixture
|
|
88
|
+
def frozen_client(monkeypatch):
|
|
89
|
+
"""A client with frozen clock/uuid/identity per the envelope vectors."""
|
|
90
|
+
from azfive_capture import client as client_mod
|
|
91
|
+
from azfive_capture import transport as transport_mod
|
|
92
|
+
|
|
93
|
+
envelope = load_vectors("event-envelope")
|
|
94
|
+
monkeypatch.setattr(client_mod, "_now_iso", lambda: envelope["frozen_timestamp"])
|
|
95
|
+
monkeypatch.setattr(transport_mod, "uuid4", lambda: "u-1")
|
|
96
|
+
monkeypatch.setattr(client_mod.transport, "uuid4", lambda: "u-1")
|
|
97
|
+
|
|
98
|
+
c = client_mod.Client(
|
|
99
|
+
"azfive_test_key", host="http://unused.local",
|
|
100
|
+
disable_decide=True, start_flush_thread=False,
|
|
101
|
+
)
|
|
102
|
+
c._distinct_id = "anon-1"
|
|
103
|
+
c._device_id = "dev-1"
|
|
104
|
+
return c
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Batching semantics — golden vectors + real HTTP against the mock server."""
|
|
2
|
+
|
|
3
|
+
from conftest import load_vectors
|
|
4
|
+
|
|
5
|
+
from azfive_capture.client import Client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ScriptedTransport:
|
|
9
|
+
"""Records batches; returns scripted ok/fail per flush call."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.batches: list[list[str]] = []
|
|
13
|
+
self.results: list[bool] = []
|
|
14
|
+
|
|
15
|
+
def send(self, batch: list[dict]) -> bool:
|
|
16
|
+
self.batches.append([e["event"] for e in batch])
|
|
17
|
+
return self.results.pop(0) if self.results else True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _make_client(config: dict) -> tuple[Client, ScriptedTransport]:
|
|
21
|
+
c = Client(
|
|
22
|
+
"azfive_test", host="http://unused.local",
|
|
23
|
+
batch_size=config.get("batch_size", 10),
|
|
24
|
+
max_queue_size=config.get("max_queue_size", 1000),
|
|
25
|
+
disable_decide=True, start_flush_thread=False,
|
|
26
|
+
)
|
|
27
|
+
scripted = ScriptedTransport()
|
|
28
|
+
c._send = scripted.send # type: ignore[method-assign]
|
|
29
|
+
return c, scripted
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_batching_vectors():
|
|
33
|
+
vectors = load_vectors("batching")
|
|
34
|
+
for case in vectors["cases"]:
|
|
35
|
+
client, transport = _make_client(case.get("config", {}))
|
|
36
|
+
for step in case["actions"]:
|
|
37
|
+
if step.startswith("capture "):
|
|
38
|
+
client.capture(step.split(" ", 1)[1])
|
|
39
|
+
elif step == "flush":
|
|
40
|
+
client.flush()
|
|
41
|
+
elif step == "flush(ok)":
|
|
42
|
+
transport.results.append(True)
|
|
43
|
+
client.flush()
|
|
44
|
+
elif step == "flush(fail)":
|
|
45
|
+
transport.results.append(False)
|
|
46
|
+
client.flush()
|
|
47
|
+
elif step == "opt_out":
|
|
48
|
+
client.opt_out_capturing()
|
|
49
|
+
elif step == "opt_in":
|
|
50
|
+
client.opt_in_capturing()
|
|
51
|
+
else:
|
|
52
|
+
raise AssertionError(f"unknown step {step}")
|
|
53
|
+
assert transport.batches == case["expected_batches"], case["name"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_real_http_delivery(mock_server):
|
|
57
|
+
client = Client(
|
|
58
|
+
"azfive_secret_abc", host=mock_server.host,
|
|
59
|
+
batch_size=2, disable_decide=True, start_flush_thread=False,
|
|
60
|
+
)
|
|
61
|
+
client.capture("e1")
|
|
62
|
+
client.capture("e2") # triggers flush at batch_size
|
|
63
|
+
assert mock_server.events_batches() == [["e1", "e2"]]
|
|
64
|
+
req = mock_server.requests[0]
|
|
65
|
+
assert req["path"] == "/api/v1/events" # secret key → server path
|
|
66
|
+
assert req["headers"]["Authorization"] == "Bearer azfive_secret_abc"
|
|
67
|
+
assert req["body"]["project"] == "default"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_public_token_routes_to_ingest_path(mock_server):
|
|
71
|
+
client = Client(
|
|
72
|
+
"azfive_pub_abc", host=mock_server.host,
|
|
73
|
+
batch_size=1, disable_decide=True, start_flush_thread=False,
|
|
74
|
+
)
|
|
75
|
+
client.capture("e1")
|
|
76
|
+
assert mock_server.requests[0]["path"] == "/api/v1/events/ingest"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_gzip_delivery(mock_server):
|
|
80
|
+
client = Client(
|
|
81
|
+
"azfive_secret_abc", host=mock_server.host, gzip=True,
|
|
82
|
+
batch_size=1, disable_decide=True, start_flush_thread=False,
|
|
83
|
+
)
|
|
84
|
+
client.capture("zipped")
|
|
85
|
+
req = mock_server.requests[0]
|
|
86
|
+
assert req["headers"].get("Content-Encoding") == "gzip"
|
|
87
|
+
assert req["body"]["events"][0]["event"] == "zipped" # decompressed OK
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_close_flushes(mock_server):
|
|
91
|
+
client = Client(
|
|
92
|
+
"azfive_secret_abc", host=mock_server.host,
|
|
93
|
+
batch_size=100, disable_decide=True, start_flush_thread=False,
|
|
94
|
+
)
|
|
95
|
+
client.capture("tail")
|
|
96
|
+
client.close()
|
|
97
|
+
assert mock_server.events_batches() == [["tail"]]
|
|
98
|
+
client.close() # idempotent
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Event envelope + property merge — golden vectors from sdk-spec/vectors/."""
|
|
2
|
+
|
|
3
|
+
from conftest import load_vectors
|
|
4
|
+
|
|
5
|
+
from azfive_capture.client import LIB_NAME, LIB_VERSION, merge_properties
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _run_action(client, action):
|
|
9
|
+
method = action["method"]
|
|
10
|
+
if method == "capture":
|
|
11
|
+
client.capture(action["event"], action.get("properties") or {})
|
|
12
|
+
elif method == "identify":
|
|
13
|
+
client.identify(action["distinct_id"], set=action.get("set"), set_once=action.get("set_once"))
|
|
14
|
+
elif method == "alias":
|
|
15
|
+
client.alias(action["alias"])
|
|
16
|
+
elif method == "people.set":
|
|
17
|
+
client.people.set(action["properties"])
|
|
18
|
+
elif method == "people.set_once":
|
|
19
|
+
client.people.set_once(action["properties"])
|
|
20
|
+
elif method == "az.flag_called":
|
|
21
|
+
client.capture(
|
|
22
|
+
"az.flag_called",
|
|
23
|
+
{"$feature_flag": action["flag"], "$feature_flag_response": action["response"]},
|
|
24
|
+
)
|
|
25
|
+
else:
|
|
26
|
+
raise AssertionError(f"unknown action {method}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_event_envelope_vectors(frozen_client):
|
|
30
|
+
vectors = load_vectors("event-envelope")
|
|
31
|
+
ignore = set(vectors["ignore_property_keys"])
|
|
32
|
+
for case in vectors["cases"]:
|
|
33
|
+
# Each case assumes a fresh anonymous client.
|
|
34
|
+
frozen_client._queue.clear()
|
|
35
|
+
frozen_client._distinct_id = "anon-1"
|
|
36
|
+
frozen_client._device_id = "dev-1"
|
|
37
|
+
frozen_client._identified = False
|
|
38
|
+
frozen_client._super_props = {}
|
|
39
|
+
for setup in case.get("setup", []):
|
|
40
|
+
_run_action(frozen_client, setup)
|
|
41
|
+
frozen_client._queue.clear() # setup events aren't asserted
|
|
42
|
+
_run_action(frozen_client, case["action"])
|
|
43
|
+
assert len(frozen_client._queue) == 1, case["name"]
|
|
44
|
+
actual = dict(frozen_client._queue[0])
|
|
45
|
+
expected = case["expected"]
|
|
46
|
+
actual["properties"] = {
|
|
47
|
+
k: v for k, v in actual["properties"].items()
|
|
48
|
+
if k not in ignore or k in expected["properties"]
|
|
49
|
+
}
|
|
50
|
+
assert actual == expected, case["name"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_lib_stamps_present(frozen_client):
|
|
54
|
+
frozen_client.capture("e")
|
|
55
|
+
props = frozen_client._queue[0]["properties"]
|
|
56
|
+
assert props["$lib"] == LIB_NAME == "azfive-python"
|
|
57
|
+
assert props["$lib_version"] == LIB_VERSION
|
|
58
|
+
assert props["$device_type"] == "Server"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_property_merge_vectors(frozen_client):
|
|
62
|
+
vectors = load_vectors("property-merge")
|
|
63
|
+
for case in vectors["cases"]:
|
|
64
|
+
if "register_sequence" in case:
|
|
65
|
+
frozen_client._super_props = {}
|
|
66
|
+
for step in case["register_sequence"]:
|
|
67
|
+
if step["method"] == "register":
|
|
68
|
+
frozen_client.register(step["props"])
|
|
69
|
+
elif step["method"] == "register_once":
|
|
70
|
+
frozen_client.register_once(step["props"])
|
|
71
|
+
elif step["method"] == "unregister":
|
|
72
|
+
frozen_client.unregister(step["key"])
|
|
73
|
+
assert frozen_client._super_props == case["expected_super"], case["name"]
|
|
74
|
+
elif "expected_reserved" in case:
|
|
75
|
+
frozen_client._super_props = dict(case.get("super") or {})
|
|
76
|
+
frozen_client._queue.clear()
|
|
77
|
+
frozen_client.capture("e", dict(case.get("event") or {}))
|
|
78
|
+
props = frozen_client._queue[0]["properties"]
|
|
79
|
+
assert props["$lib"] == LIB_NAME, case["name"]
|
|
80
|
+
assert props["$lib_version"] == LIB_VERSION, case["name"]
|
|
81
|
+
assert props["$device_id"] == "dev-1", case["name"]
|
|
82
|
+
frozen_client._super_props = {}
|
|
83
|
+
else:
|
|
84
|
+
merged = merge_properties(
|
|
85
|
+
case.get("static") or {}, case.get("session") or {},
|
|
86
|
+
case.get("super") or {}, case.get("event") or {}, {},
|
|
87
|
+
)
|
|
88
|
+
assert merged == case["expected"], case["name"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_reset_preserves_optout_and_device_id(frozen_client):
|
|
92
|
+
frozen_client.register({"team": "core"})
|
|
93
|
+
frozen_client.identify("ava@example.com")
|
|
94
|
+
frozen_client.opt_out_capturing()
|
|
95
|
+
device = frozen_client._device_id
|
|
96
|
+
|
|
97
|
+
frozen_client.reset()
|
|
98
|
+
assert frozen_client.get_distinct_id() != "ava@example.com"
|
|
99
|
+
assert frozen_client._super_props == {}
|
|
100
|
+
assert frozen_client.has_opted_out_capturing() is True
|
|
101
|
+
assert frozen_client._device_id == device
|
|
102
|
+
|
|
103
|
+
frozen_client.reset(reset_device_id=True)
|
|
104
|
+
assert frozen_client._device_id != device or frozen_client._device_id == "u-1"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Flags: decide client, TTL cache, exposure dedup — golden vectors."""
|
|
2
|
+
|
|
3
|
+
from conftest import load_vectors
|
|
4
|
+
|
|
5
|
+
from azfive_capture.flags import FlagsManager
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeClock:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self.t = 0.0
|
|
11
|
+
|
|
12
|
+
def __call__(self) -> float:
|
|
13
|
+
return self.t
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _manager(flags_doc: dict, captured: list, clock: FakeClock | None = None):
|
|
17
|
+
state = {"doc": {"flags": dict(flags_doc), "flagPayloads": {}}, "calls": 0}
|
|
18
|
+
|
|
19
|
+
def capture(event, properties, *, distinct_id):
|
|
20
|
+
captured.append({"event": event, "properties": properties, "distinct_id": distinct_id})
|
|
21
|
+
|
|
22
|
+
mgr = FlagsManager(
|
|
23
|
+
host="http://unused.local", api_key="k", project="default",
|
|
24
|
+
capture=capture, now=clock or FakeClock(),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def fake_decide(url, api_key, payload):
|
|
28
|
+
state["calls"] += 1
|
|
29
|
+
return state["doc"]
|
|
30
|
+
|
|
31
|
+
import azfive_capture.flags as flags_mod
|
|
32
|
+
original = flags_mod.transport.post_json
|
|
33
|
+
flags_mod.transport.post_json = fake_decide # patched for the test process
|
|
34
|
+
mgr._restore = lambda: setattr(flags_mod.transport, "post_json", original) # type: ignore[attr-defined]
|
|
35
|
+
return mgr, state
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_exposure_dedup_vectors():
|
|
39
|
+
vectors = load_vectors("exposure-dedup")
|
|
40
|
+
for case in vectors["cases"]:
|
|
41
|
+
captured: list = []
|
|
42
|
+
mgr, state = _manager(vectors["flags"], captured)
|
|
43
|
+
try:
|
|
44
|
+
distinct_id = "user-1"
|
|
45
|
+
for call in case["calls"]:
|
|
46
|
+
method = call["method"]
|
|
47
|
+
if method == "getFlag":
|
|
48
|
+
result = mgr.get_flag(distinct_id, call["key"])
|
|
49
|
+
if "expected_return" in case and case["expected_return"] is None:
|
|
50
|
+
assert result is None, case["name"]
|
|
51
|
+
elif method == "isFlagEnabled":
|
|
52
|
+
result = mgr.is_flag_enabled(distinct_id, call["key"])
|
|
53
|
+
if "expect" in call:
|
|
54
|
+
assert result is call["expect"], f"{case['name']}: {call['key']}"
|
|
55
|
+
elif method == "getFlagPayload":
|
|
56
|
+
mgr.get_flag_payload(distinct_id, call["key"])
|
|
57
|
+
elif method == "_update_flag":
|
|
58
|
+
state["doc"]["flags"][call["key"]] = call["value"]
|
|
59
|
+
mgr.reload_flags(distinct_id)
|
|
60
|
+
elif method == "_new_scope":
|
|
61
|
+
distinct_id = "user-2" # server dedup scope = distinct_id
|
|
62
|
+
else:
|
|
63
|
+
raise AssertionError(method)
|
|
64
|
+
if "expected_exposures" in case:
|
|
65
|
+
actual = [
|
|
66
|
+
{"$feature_flag": c["properties"]["$feature_flag"],
|
|
67
|
+
"$feature_flag_response": c["properties"]["$feature_flag_response"]}
|
|
68
|
+
for c in captured
|
|
69
|
+
]
|
|
70
|
+
assert actual == case["expected_exposures"], case["name"]
|
|
71
|
+
finally:
|
|
72
|
+
mgr._restore() # type: ignore[attr-defined]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_decide_ttl_cache():
|
|
76
|
+
clock = FakeClock()
|
|
77
|
+
captured: list = []
|
|
78
|
+
mgr, state = _manager({"f": True}, captured, clock)
|
|
79
|
+
try:
|
|
80
|
+
mgr.get_flag("u1", "f")
|
|
81
|
+
mgr.get_flag("u1", "f")
|
|
82
|
+
assert state["calls"] == 1 # cached
|
|
83
|
+
clock.t += 61
|
|
84
|
+
mgr.get_flag("u1", "f")
|
|
85
|
+
assert state["calls"] == 2 # TTL expired
|
|
86
|
+
mgr.reload_flags("u1")
|
|
87
|
+
mgr.get_flag("u1", "f")
|
|
88
|
+
assert state["calls"] == 3 # busted
|
|
89
|
+
mgr.get_flag("u2", "f")
|
|
90
|
+
assert state["calls"] == 4 # per-distinct_id cache
|
|
91
|
+
finally:
|
|
92
|
+
mgr._restore() # type: ignore[attr-defined]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_decide_request_shape(mock_server):
|
|
96
|
+
from azfive_capture import Client
|
|
97
|
+
|
|
98
|
+
fixtures = load_vectors("decide-fixtures")
|
|
99
|
+
mock_server.decide_response = fixtures["response_full"]
|
|
100
|
+
client = Client("azfive_secret_abc", host=mock_server.host, start_flush_thread=False)
|
|
101
|
+
|
|
102
|
+
assert client.get_flag("anon-1", "experiment") == "variant-b"
|
|
103
|
+
assert client.get_flag_payload("anon-1", "experiment") == {"cta": "Try it"}
|
|
104
|
+
assert client.is_flag_enabled("anon-1", "boolean-off") is False
|
|
105
|
+
|
|
106
|
+
decide_reqs = [r for r in mock_server.requests if r["path"].endswith("/decide")]
|
|
107
|
+
assert decide_reqs[0]["body"] == fixtures["request"]["body"]
|
|
108
|
+
assert decide_reqs[0]["headers"]["Authorization"] == "Bearer azfive_secret_abc"
|
|
109
|
+
|
|
110
|
+
# exposures were captured for getFlag/isFlagEnabled but not payload
|
|
111
|
+
client.flush()
|
|
112
|
+
exposure_events = [
|
|
113
|
+
e for r in mock_server.requests if "/events" in r["path"]
|
|
114
|
+
for e in r["body"]["events"] if e["event"] == "az.flag_called"
|
|
115
|
+
]
|
|
116
|
+
responses = {(e["properties"]["$feature_flag"], e["properties"]["$feature_flag_response"])
|
|
117
|
+
for e in exposure_events}
|
|
118
|
+
assert responses == {("experiment", "variant-b"), ("boolean-off", False)}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_decide_failure_returns_stale_or_none():
|
|
122
|
+
captured: list = []
|
|
123
|
+
mgr, state = _manager({"f": True}, captured)
|
|
124
|
+
try:
|
|
125
|
+
assert mgr.get_flag("u1", "f") is True
|
|
126
|
+
state["doc"] = None # network failure → post_json returns None
|
|
127
|
+
|
|
128
|
+
def failing(url, api_key, payload):
|
|
129
|
+
return None
|
|
130
|
+
import azfive_capture.flags as flags_mod
|
|
131
|
+
flags_mod.transport.post_json = failing
|
|
132
|
+
mgr._decide_cache.remove("u1")
|
|
133
|
+
assert mgr.get_flag("u1", "f") is None # no stale entry → empty doc
|
|
134
|
+
finally:
|
|
135
|
+
mgr._restore() # type: ignore[attr-defined]
|