controlvar 0.1.1__tar.gz → 0.2.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.
Files changed (34) hide show
  1. {controlvar-0.1.1 → controlvar-0.2.0}/PKG-INFO +43 -11
  2. {controlvar-0.1.1 → controlvar-0.2.0}/README.md +42 -10
  3. controlvar-0.2.0/controlvar/attribution.py +105 -0
  4. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/client.py +134 -26
  5. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/types.py +3 -1
  6. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/version.py +1 -1
  7. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar.egg-info/PKG-INFO +43 -11
  8. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar.egg-info/SOURCES.txt +2 -0
  9. {controlvar-0.1.1 → controlvar-0.2.0}/pyproject.toml +1 -1
  10. controlvar-0.2.0/tests/test_attribution.py +90 -0
  11. {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_client.py +370 -12
  12. {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_contract_smoke.py +37 -0
  13. {controlvar-0.1.1 → controlvar-0.2.0}/LICENSE +0 -0
  14. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/__init__.py +0 -0
  15. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/cache.py +0 -0
  16. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/evaluator.py +0 -0
  17. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/hashing.py +0 -0
  18. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/local.py +0 -0
  19. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/py.typed +0 -0
  20. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/testing.py +0 -0
  21. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/transport.py +0 -0
  22. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar.egg-info/dependency_links.txt +0 -0
  23. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar.egg-info/requires.txt +0 -0
  24. {controlvar-0.1.1 → controlvar-0.2.0}/controlvar.egg-info/top_level.txt +0 -0
  25. {controlvar-0.1.1 → controlvar-0.2.0}/setup.cfg +0 -0
  26. {controlvar-0.1.1 → controlvar-0.2.0}/setup.py +0 -0
  27. {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_evaluator.py +0 -0
  28. {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_hashing.py +0 -0
  29. {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_local.py +0 -0
  30. {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_no_real_aws.py +0 -0
  31. {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_package.py +0 -0
  32. {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_readme.py +0 -0
  33. {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_transport.py +0 -0
  34. {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlvar
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Python client for the Controlvar public API (experiments, feature flags, event tracking)
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/Controlvar/controlvar/tree/main/sdk/python#readme
@@ -42,11 +42,11 @@ else to install.
42
42
 
43
43
  If you installed `experimently`, the name this package had before the project was renamed, uninstall it and install `controlvar`.
44
44
 
45
- The `controlvar` package is on [PyPI](https://pypi.org/project/controlvar/). 0.1.1 is a
45
+ The `controlvar` package is on [PyPI](https://pypi.org/project/controlvar/). 0.2.0 is a
46
46
  beta release. It installs and imports as `controlvar`.
47
47
 
48
48
  ```bash
49
- pip install controlvar==0.1.1
49
+ pip install controlvar==0.2.0
50
50
  ```
51
51
 
52
52
  To work on the SDK itself, install it from the root of a clone of this repository instead:
@@ -55,6 +55,22 @@ To work on the SDK itself, install it from the root of a clone of this repositor
55
55
  pip install -e sdk/python
56
56
  ```
57
57
 
58
+ ## Changes in this version
59
+
60
+ - `report_error()` is new: it sends one error report to the server and returns `True` when the
61
+ server stored it.
62
+ - `track()` returns `True` when the event was sent and the server answered 2xx, and `False`
63
+ otherwise. Before this version, a `track()` without a key returned `True` only when the server
64
+ accepted every entry it was fanned out to. Now a batch answered 2xx returns `True` even when the
65
+ server refused one of its entries; `track_batch()` returns the per-entry results.
66
+ - A `track()` without a key still reaches the experiments and flags this client answered for the
67
+ user in the last 24 hours (with the exceptions under Tracking fan-out rule) after their cached
68
+ answers expire; before this version it reached only the answers still in the cache. Those keys
69
+ are this client instance's only: an event tracked by another instance, in this process or
70
+ another, reaches none of them.
71
+ - With nothing to reach, a `track()` without a key is now sent once with no key and stored as
72
+ history, and returns `True` on a 2xx; before this version it sent nothing and returned `False`.
73
+
58
74
  ## Quick start
59
75
 
60
76
  Set `CONTROLVAR_API_KEY` to an API key, then run the block as it is; the two `print` calls
@@ -95,16 +111,17 @@ default_variant="control", transport=None)`. Instances are thread-safe.
95
111
 
96
112
  | Method | Returns | Failure behaviour |
97
113
  |---|---|---|
98
- | `get_assignment(experiment_key, user_id, user_attributes=None)` | `Assignment(experiment_key, user_id, variant_id, variant_name, is_control, configuration, assigned, reason)` | raises `ExperimentationError` (`status == 404` when the experiment is not ACTIVE) |
114
+ | `get_assignment(experiment_key, user_id, user_attributes=None)` | `Assignment(experiment_key, user_id, variant_id, variant_name, is_control, configuration, assigned, reason)` | raises `ExperimentationError` (`status == 404` when no experiment has the key, or for a draft with no control variant); an experiment that is not ACTIVE returns its control variant with `assigned` False and `reason` its status |
99
115
  | `get_variant(experiment_key, user_id, user_attributes=None)` | `str` variant name | returns `default_variant` |
100
116
  | `get_feature_flag(flag_key, user_id, user_attributes=None)` | `FlagEvaluation(key, enabled, config, reason)` | raises `ExperimentationError` (`status == 404` when the flag key is unknown; a flag that is not ACTIVE returns `enabled=False`, `reason="inactive"`) |
101
117
  | `is_feature_enabled(flag_key, user_id, user_attributes=None)` | `bool` | returns `False` |
102
118
  | `get_all_flags(user_id, user_attributes=None)` | `dict[str, bool]` (not cached) | raises `ExperimentationError` |
103
- | `track(user_id, event_name, event_value=None, properties=None, experiment_key=None, feature_flag_key=None, event_type=None, timestamp=None)` | `bool` — `True` when accepted | never raises; `False` on failure or when nothing was sent |
119
+ | `track(user_id, event_name, event_value=None, properties=None, experiment_key=None, feature_flag_key=None, event_type=None, timestamp=None)` | `bool` — `True` when sent and answered 2xx | never raises; `False` on failure |
104
120
  | `track_batch(events)` | `BatchResult(success_count, failure_count, errors)`; `.ok` | never raises; a failed chunk counts all its events as failures |
121
+ | `report_error(error_type, message, *, feature_flag_key=None, experiment_key=None, user_id=None, stack_trace=None, metadata=None, timestamp=None)` | `bool` — `True` when the server stored the report (201) | never raises; `False` and a logged warning otherwise |
105
122
  | `get_assignments(user_id, active_only=True)` | `list[dict]` from the server | raises `ExperimentationError` |
106
123
  | `cached_assignments(user_id)` / `cached_flags(user_id)` | cached, unexpired entries | — |
107
- | `clear_cache()` | — | — |
124
+ | `clear_cache()` | — (keeps the keys a key-less `track` is sent to) | — |
108
125
  | `ready(timeout_seconds=None)`, `status()`, `close()` / `with` | local mode: readiness, ruleset state, shutdown (sends evaluation counts) | never raise |
109
126
  | `consistent_hash(user_id, flag_key)` / `md5_hex(user_id, flag_key)` | cross-SDK MD5 bucket in `[0, 1)` / hex digest | pure functions; **not** the flag bucketing function |
110
127
 
@@ -119,6 +136,11 @@ retried once after `Retry-After` seconds (capped at 5 s).
119
136
  the server decided. Caches are keyed by user + key only — call `clear_cache()` after changing a
120
137
  user's attributes.
121
138
 
139
+ `report_error` returns `True` when the server stored the report. A stored report counts toward the
140
+ flag's error rate and automatic rollback only when the client's key has the `sdk:ruleset` scope and
141
+ was created by a user who can change feature flags; the flag's safety check shows the others as
142
+ `uncounted_error_count`.
143
+
122
144
  ### Enrolment: `assigned` and `reason`
123
145
 
124
146
  An `Assignment` also carries `assigned` and `reason`. `assigned is False` means the server did not
@@ -129,13 +151,22 @@ and `reason` says why. A server that predates the fields sends neither, so `assi
129
151
  (never `False`). Decide deliberately how to treat those rows rather than dropping them with an
130
152
  `assigned is True` filter.
131
153
 
154
+ An experiment that is not ACTIVE answers every user with its control variant, `assigned` False and
155
+ `reason` set to its status (`"draft"`, `"paused"`, `"completed"` or `"archived"`).
156
+
132
157
  ## Tracking fan-out rule
133
158
 
134
159
  - **with** `experiment_key` and/or `feature_flag_key` → one `POST /api/v1/tracking/track`;
135
160
  - **without** a key → one `POST /api/v1/tracking/batch` (chunks of 100) containing one entry per
136
- experiment the user has been assigned to in this client (`experiment_key`) plus one per flag
137
- evaluated for the user (`feature_flag_key`), taken from the cache (successful, unexpired only);
138
- - nothing cached for the user → nothing is sent and `track` returns `False`.
161
+ experiment (`experiment_key`) and one per flag (`feature_flag_key`) this client answered for the
162
+ user in the last 24 hours, cached or not, except an experiment whose latest answer has `reason`
163
+ `"draft"`, `"paused"`, `"completed"` or `"archived"`;
164
+ - nothing to attribute it to → one `POST /api/v1/tracking/track` with no key, stored as history (it
165
+ counts in holdout results and as CUPED history, never in an experiment's results);
166
+ - `track` returns `True` when the event was sent and the server answered 2xx, `False` otherwise.
167
+
168
+ The keys live in this client's memory, for up to 10,000 users; `clear_cache()` keeps them, and
169
+ another process does not have them. In a server, pass `experiment_key` on conversions.
139
170
 
140
171
  `event_type` defaults to `event_name`; `properties` is sent as `metadata`; `timestamp` may be a
141
172
  `datetime` (naive values are treated as UTC) or an ISO-8601 string.
@@ -146,11 +177,12 @@ Every request carries `X-API-Key: <key>`, `Content-Type: application/json` and `
146
177
 
147
178
  | SDK call | Method and path | Body / query | Response used |
148
179
  |---|---|---|---|
149
- | `get_assignment`, `get_variant` | `POST /api/v1/tracking/assign` | `{experiment_key, user_id, context?}` | `{experiment_key, user_id, variant_id, variant_name, is_control, configuration}`; 404 when not ACTIVE |
180
+ | `get_assignment`, `get_variant` | `POST /api/v1/tracking/assign` | `{experiment_key, user_id, context?}` | `{experiment_key, user_id, variant_id, variant_name, is_control, configuration}`; 404 when no experiment has the key, or for a draft with no control variant; an experiment that is not ACTIVE answers its control variant with `assigned: false` |
150
181
  | `get_feature_flag`, `is_feature_enabled` | `GET /api/v1/feature-flags/evaluate/{flag_key}?user_id=…&context=<url-encoded JSON>` | `context` = `user_attributes` (omitted when empty) | `{key, enabled, config, reason}`; off with `reason: "inactive"` when the flag exists but is not ACTIVE; 404 only for an unknown key |
151
182
  | `get_all_flags` | `GET /api/v1/feature-flags/user/{user_id}?context=<url-encoded JSON>` | `context` = `user_attributes` (omitted when empty) | `{"<flag_key>": bool, …}` |
152
183
  | `track` with a key | `POST /api/v1/tracking/track` | `{event_type, event_name, user_id, experiment_key?, feature_flag_key?, value?, metadata?, timestamp?}` | ignored |
153
- | `track` without keys, `track_batch` | `POST /api/v1/tracking/batch` | `{events: [<track body>, …]}` (≤ 100 per request) | `{success_count, failure_count, errors}` |
184
+ | `track` without keys | `POST /api/v1/tracking/batch`, one entry per experiment and flag it reaches; with none, `POST /api/v1/tracking/track` | `{events: [<track body>, …]}` (≤ 100 per request); with none, the track body with no key | ignored |
185
+ | `track_batch` | `POST /api/v1/tracking/batch` | `{events: [<track body>, …]}` (≤ 100 per request) | `{success_count, failure_count, errors}` |
154
186
  | `get_assignments` | `GET /api/v1/tracking/assignments/{user_id}?active_only=true` | — | list of dicts |
155
187
 
156
188
  ## Contract smoke
@@ -16,11 +16,11 @@ else to install.
16
16
 
17
17
  If you installed `experimently`, the name this package had before the project was renamed, uninstall it and install `controlvar`.
18
18
 
19
- The `controlvar` package is on [PyPI](https://pypi.org/project/controlvar/). 0.1.1 is a
19
+ The `controlvar` package is on [PyPI](https://pypi.org/project/controlvar/). 0.2.0 is a
20
20
  beta release. It installs and imports as `controlvar`.
21
21
 
22
22
  ```bash
23
- pip install controlvar==0.1.1
23
+ pip install controlvar==0.2.0
24
24
  ```
25
25
 
26
26
  To work on the SDK itself, install it from the root of a clone of this repository instead:
@@ -29,6 +29,22 @@ To work on the SDK itself, install it from the root of a clone of this repositor
29
29
  pip install -e sdk/python
30
30
  ```
31
31
 
32
+ ## Changes in this version
33
+
34
+ - `report_error()` is new: it sends one error report to the server and returns `True` when the
35
+ server stored it.
36
+ - `track()` returns `True` when the event was sent and the server answered 2xx, and `False`
37
+ otherwise. Before this version, a `track()` without a key returned `True` only when the server
38
+ accepted every entry it was fanned out to. Now a batch answered 2xx returns `True` even when the
39
+ server refused one of its entries; `track_batch()` returns the per-entry results.
40
+ - A `track()` without a key still reaches the experiments and flags this client answered for the
41
+ user in the last 24 hours (with the exceptions under Tracking fan-out rule) after their cached
42
+ answers expire; before this version it reached only the answers still in the cache. Those keys
43
+ are this client instance's only: an event tracked by another instance, in this process or
44
+ another, reaches none of them.
45
+ - With nothing to reach, a `track()` without a key is now sent once with no key and stored as
46
+ history, and returns `True` on a 2xx; before this version it sent nothing and returned `False`.
47
+
32
48
  ## Quick start
33
49
 
34
50
  Set `CONTROLVAR_API_KEY` to an API key, then run the block as it is; the two `print` calls
@@ -69,16 +85,17 @@ default_variant="control", transport=None)`. Instances are thread-safe.
69
85
 
70
86
  | Method | Returns | Failure behaviour |
71
87
  |---|---|---|
72
- | `get_assignment(experiment_key, user_id, user_attributes=None)` | `Assignment(experiment_key, user_id, variant_id, variant_name, is_control, configuration, assigned, reason)` | raises `ExperimentationError` (`status == 404` when the experiment is not ACTIVE) |
88
+ | `get_assignment(experiment_key, user_id, user_attributes=None)` | `Assignment(experiment_key, user_id, variant_id, variant_name, is_control, configuration, assigned, reason)` | raises `ExperimentationError` (`status == 404` when no experiment has the key, or for a draft with no control variant); an experiment that is not ACTIVE returns its control variant with `assigned` False and `reason` its status |
73
89
  | `get_variant(experiment_key, user_id, user_attributes=None)` | `str` variant name | returns `default_variant` |
74
90
  | `get_feature_flag(flag_key, user_id, user_attributes=None)` | `FlagEvaluation(key, enabled, config, reason)` | raises `ExperimentationError` (`status == 404` when the flag key is unknown; a flag that is not ACTIVE returns `enabled=False`, `reason="inactive"`) |
75
91
  | `is_feature_enabled(flag_key, user_id, user_attributes=None)` | `bool` | returns `False` |
76
92
  | `get_all_flags(user_id, user_attributes=None)` | `dict[str, bool]` (not cached) | raises `ExperimentationError` |
77
- | `track(user_id, event_name, event_value=None, properties=None, experiment_key=None, feature_flag_key=None, event_type=None, timestamp=None)` | `bool` — `True` when accepted | never raises; `False` on failure or when nothing was sent |
93
+ | `track(user_id, event_name, event_value=None, properties=None, experiment_key=None, feature_flag_key=None, event_type=None, timestamp=None)` | `bool` — `True` when sent and answered 2xx | never raises; `False` on failure |
78
94
  | `track_batch(events)` | `BatchResult(success_count, failure_count, errors)`; `.ok` | never raises; a failed chunk counts all its events as failures |
95
+ | `report_error(error_type, message, *, feature_flag_key=None, experiment_key=None, user_id=None, stack_trace=None, metadata=None, timestamp=None)` | `bool` — `True` when the server stored the report (201) | never raises; `False` and a logged warning otherwise |
79
96
  | `get_assignments(user_id, active_only=True)` | `list[dict]` from the server | raises `ExperimentationError` |
80
97
  | `cached_assignments(user_id)` / `cached_flags(user_id)` | cached, unexpired entries | — |
81
- | `clear_cache()` | — | — |
98
+ | `clear_cache()` | — (keeps the keys a key-less `track` is sent to) | — |
82
99
  | `ready(timeout_seconds=None)`, `status()`, `close()` / `with` | local mode: readiness, ruleset state, shutdown (sends evaluation counts) | never raise |
83
100
  | `consistent_hash(user_id, flag_key)` / `md5_hex(user_id, flag_key)` | cross-SDK MD5 bucket in `[0, 1)` / hex digest | pure functions; **not** the flag bucketing function |
84
101
 
@@ -93,6 +110,11 @@ retried once after `Retry-After` seconds (capped at 5 s).
93
110
  the server decided. Caches are keyed by user + key only — call `clear_cache()` after changing a
94
111
  user's attributes.
95
112
 
113
+ `report_error` returns `True` when the server stored the report. A stored report counts toward the
114
+ flag's error rate and automatic rollback only when the client's key has the `sdk:ruleset` scope and
115
+ was created by a user who can change feature flags; the flag's safety check shows the others as
116
+ `uncounted_error_count`.
117
+
96
118
  ### Enrolment: `assigned` and `reason`
97
119
 
98
120
  An `Assignment` also carries `assigned` and `reason`. `assigned is False` means the server did not
@@ -103,13 +125,22 @@ and `reason` says why. A server that predates the fields sends neither, so `assi
103
125
  (never `False`). Decide deliberately how to treat those rows rather than dropping them with an
104
126
  `assigned is True` filter.
105
127
 
128
+ An experiment that is not ACTIVE answers every user with its control variant, `assigned` False and
129
+ `reason` set to its status (`"draft"`, `"paused"`, `"completed"` or `"archived"`).
130
+
106
131
  ## Tracking fan-out rule
107
132
 
108
133
  - **with** `experiment_key` and/or `feature_flag_key` → one `POST /api/v1/tracking/track`;
109
134
  - **without** a key → one `POST /api/v1/tracking/batch` (chunks of 100) containing one entry per
110
- experiment the user has been assigned to in this client (`experiment_key`) plus one per flag
111
- evaluated for the user (`feature_flag_key`), taken from the cache (successful, unexpired only);
112
- - nothing cached for the user → nothing is sent and `track` returns `False`.
135
+ experiment (`experiment_key`) and one per flag (`feature_flag_key`) this client answered for the
136
+ user in the last 24 hours, cached or not, except an experiment whose latest answer has `reason`
137
+ `"draft"`, `"paused"`, `"completed"` or `"archived"`;
138
+ - nothing to attribute it to → one `POST /api/v1/tracking/track` with no key, stored as history (it
139
+ counts in holdout results and as CUPED history, never in an experiment's results);
140
+ - `track` returns `True` when the event was sent and the server answered 2xx, `False` otherwise.
141
+
142
+ The keys live in this client's memory, for up to 10,000 users; `clear_cache()` keeps them, and
143
+ another process does not have them. In a server, pass `experiment_key` on conversions.
113
144
 
114
145
  `event_type` defaults to `event_name`; `properties` is sent as `metadata`; `timestamp` may be a
115
146
  `datetime` (naive values are treated as UTC) or an ISO-8601 string.
@@ -120,11 +151,12 @@ Every request carries `X-API-Key: <key>`, `Content-Type: application/json` and `
120
151
 
121
152
  | SDK call | Method and path | Body / query | Response used |
122
153
  |---|---|---|---|
123
- | `get_assignment`, `get_variant` | `POST /api/v1/tracking/assign` | `{experiment_key, user_id, context?}` | `{experiment_key, user_id, variant_id, variant_name, is_control, configuration}`; 404 when not ACTIVE |
154
+ | `get_assignment`, `get_variant` | `POST /api/v1/tracking/assign` | `{experiment_key, user_id, context?}` | `{experiment_key, user_id, variant_id, variant_name, is_control, configuration}`; 404 when no experiment has the key, or for a draft with no control variant; an experiment that is not ACTIVE answers its control variant with `assigned: false` |
124
155
  | `get_feature_flag`, `is_feature_enabled` | `GET /api/v1/feature-flags/evaluate/{flag_key}?user_id=…&context=<url-encoded JSON>` | `context` = `user_attributes` (omitted when empty) | `{key, enabled, config, reason}`; off with `reason: "inactive"` when the flag exists but is not ACTIVE; 404 only for an unknown key |
125
156
  | `get_all_flags` | `GET /api/v1/feature-flags/user/{user_id}?context=<url-encoded JSON>` | `context` = `user_attributes` (omitted when empty) | `{"<flag_key>": bool, …}` |
126
157
  | `track` with a key | `POST /api/v1/tracking/track` | `{event_type, event_name, user_id, experiment_key?, feature_flag_key?, value?, metadata?, timestamp?}` | ignored |
127
- | `track` without keys, `track_batch` | `POST /api/v1/tracking/batch` | `{events: [<track body>, …]}` (≤ 100 per request) | `{success_count, failure_count, errors}` |
158
+ | `track` without keys | `POST /api/v1/tracking/batch`, one entry per experiment and flag it reaches; with none, `POST /api/v1/tracking/track` | `{events: [<track body>, …]}` (≤ 100 per request); with none, the track body with no key | ignored |
159
+ | `track_batch` | `POST /api/v1/tracking/batch` | `{events: [<track body>, …]}` (≤ 100 per request) | `{success_count, failure_count, errors}` |
128
160
  | `get_assignments` | `GET /api/v1/tracking/assignments/{user_id}?active_only=true` | — | list of dicts |
129
161
 
130
162
  ## Contract smoke
@@ -0,0 +1,105 @@
1
+ """The keys a key-less ``track()`` is sent to, kept apart from the TTL value cache.
2
+
3
+ Every successful answer (an assignment, ``assigned=False`` included, or a flag
4
+ evaluation, local or from the server) records its key for the user. A key-less
5
+ event goes to each key recorded for its user, so a purchase made after the cache
6
+ entry expired still reaches the experiment the user was answered for.
7
+ ``clear_cache()`` does not touch this record: a user's sticky assignments do not
8
+ change when their attributes do.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ from collections import OrderedDict
15
+ from typing import Dict, FrozenSet, List, Optional, Tuple
16
+
17
+ from . import cache
18
+
19
+ __all__ = ["ATTRIBUTION_HORIZON_SECONDS", "LIFECYCLE_REASONS", "MAX_ATTRIBUTED_USERS", "AttributedKeys"]
20
+
21
+ #: Users kept per client instance; the user answered least recently is dropped first.
22
+ MAX_ATTRIBUTED_USERS = 10_000
23
+
24
+ #: A key is used for this long after its latest successful answer, then dropped.
25
+ ATTRIBUTION_HORIZON_SECONDS = 24 * 60 * 60
26
+
27
+ #: An experiment whose latest answer gave one of these reasons is not running, so a
28
+ #: key-less event is not sent to it. Other reasons (``holdout``,
29
+ #: ``mutual_exclusion``, ``targeting``) are kept: holdout results count those
30
+ #: users' events.
31
+ LIFECYCLE_REASONS: FrozenSet[str] = frozenset({"draft", "paused", "completed", "archived"})
32
+
33
+ # key -> (answered_at, reason); dict order is first-answer order.
34
+ _Answers = Dict[str, Tuple[float, Optional[str]]]
35
+
36
+
37
+ class AttributedKeys:
38
+ """``user_id -> (experiment answers, flag answers)``, least recently answered user first.
39
+
40
+ Time is ``controlvar.cache._now`` (monotonic), read at call time, so a test that
41
+ patches it moves this record and the cache together.
42
+ """
43
+
44
+ def __init__(self) -> None:
45
+ self._lock = threading.Lock()
46
+ self._users: "OrderedDict[str, Tuple[_Answers, _Answers]]" = OrderedDict()
47
+
48
+ def record_experiment(self, user_id: str, experiment_key: str, reason: Optional[str] = None) -> None:
49
+ self._record(user_id, experiment_key, reason, 0)
50
+
51
+ def record_flag(self, user_id: str, flag_key: str) -> None:
52
+ self._record(user_id, flag_key, None, 1)
53
+
54
+ def forget_experiment(self, user_id: str, experiment_key: str) -> None:
55
+ """Drop one experiment's key for the user: the server answered its assignment 404, so
56
+ a key-less event must not reach it (a server that answers 404 for an experiment that is
57
+ not running would otherwise credit the user's stored arm while the user is shown
58
+ control)."""
59
+ with self._lock:
60
+ answers = self._users.get(user_id)
61
+ if answers is None:
62
+ return
63
+ answers[0].pop(experiment_key, None)
64
+ if not answers[0] and not answers[1]:
65
+ del self._users[user_id]
66
+
67
+ def keys(self, user_id: str) -> Tuple[List[str], List[str]]:
68
+ """The user's experiment keys and flag keys within the horizon, each in the
69
+ order they were first answered. Keys past the horizon are dropped here."""
70
+ with self._lock:
71
+ answers = self._users.get(user_id)
72
+ if answers is None:
73
+ return [], []
74
+ now = cache._now()
75
+ experiments, flags = _live(answers[0], now), _live(answers[1], now)
76
+ if not answers[0] and not answers[1]:
77
+ del self._users[user_id]
78
+ return experiments, flags
79
+
80
+ def __len__(self) -> int:
81
+ with self._lock:
82
+ return len(self._users)
83
+
84
+ def _record(self, user_id: str, key: str, reason: Optional[str], kind: int) -> None:
85
+ with self._lock:
86
+ answers = self._users.get(user_id)
87
+ if answers is None:
88
+ answers = ({}, {})
89
+ self._users[user_id] = answers
90
+ else:
91
+ self._users.move_to_end(user_id) # now the most recently answered user
92
+ # Assigning an existing key keeps its place: the order stays first-answer order.
93
+ answers[kind][key] = (cache._now(), reason)
94
+ while len(self._users) > MAX_ATTRIBUTED_USERS:
95
+ self._users.popitem(last=False)
96
+
97
+
98
+ def _live(answers: _Answers, now: float) -> List[str]:
99
+ out: List[str] = []
100
+ for key, (answered_at, reason) in list(answers.items()):
101
+ if now - answered_at > ATTRIBUTION_HORIZON_SECONDS:
102
+ del answers[key]
103
+ elif reason not in LIFECYCLE_REASONS:
104
+ out.append(key)
105
+ return out
@@ -9,11 +9,13 @@
9
9
  ruleset whenever the answer is provably the server's, and by the server
10
10
  otherwise; experiments always stay on the server.
11
11
  * Successful assignments/evaluations are cached per user + key for
12
- ``cache_ttl_seconds``; failures are never cached.
12
+ ``cache_ttl_seconds``; failures are never cached. Every successful answer also
13
+ records its key for the key-less ``track()`` fan-out, for 24 hours
14
+ (:mod:`controlvar.attribution`); ``clear_cache()`` keeps that record.
13
15
  * ``get_assignment``/``get_feature_flag``/``get_all_flags``/``get_assignments``
14
16
  raise :class:`~controlvar.ExperimentationError`; the convenience
15
17
  wrappers ``get_variant``/``is_feature_enabled`` swallow it and return a
16
- default; ``track``/``track_batch`` never raise.
18
+ default; ``track``/``track_batch``/``report_error`` never raise.
17
19
  * Safe to share between threads.
18
20
  """
19
21
 
@@ -26,6 +28,7 @@ from datetime import datetime, timezone
26
28
  from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Union
27
29
  from urllib.parse import quote, urlencode
28
30
 
31
+ from .attribution import AttributedKeys
29
32
  from .cache import UserKeyCache
30
33
  from .evaluator import DEFER
31
34
  from .local import (
@@ -146,6 +149,8 @@ class ExperimentationClient:
146
149
  self._transport: Transport = transport if transport is not None else UrllibTransport()
147
150
  self._assignments: UserKeyCache[Assignment] = UserKeyCache(cache_ttl_seconds)
148
151
  self._flags: UserKeyCache[FlagEvaluation] = UserKeyCache(cache_ttl_seconds)
152
+ # The keys a key-less track() is sent to; outlives the cache entries.
153
+ self._attributed = AttributedKeys()
149
154
  self._sleep = time.sleep
150
155
  self._headers: Dict[str, str] = {
151
156
  "X-API-Key": api_key,
@@ -235,19 +240,19 @@ class ExperimentationClient:
235
240
  except Exception as exc: # noqa: BLE001 - normalise any transport failure
236
241
  raise ExperimentationError(f"{method} {url} failed: {exc}") from exc
237
242
 
238
- def _request(
243
+ def _exchange(
239
244
  self,
240
245
  method: str,
241
246
  path: str,
242
247
  *,
243
248
  body: Optional[Mapping[str, Any]] = None,
244
249
  query: Optional[Mapping[str, str]] = None,
245
- ) -> Any:
246
- """Perform one API call and return the decoded JSON body (``None`` if empty).
250
+ ) -> Response:
251
+ """Send one API call and return its response, whatever the status.
247
252
 
248
253
  Retries exactly once on 429, sleeping for ``Retry-After`` (capped at
249
254
  :data:`MAX_RETRY_AFTER_SECONDS`). Raises :class:`ExperimentationError`
250
- for any non-2xx response, network failure or undecodable body.
255
+ for a network failure, and ``TypeError`` for a body JSON cannot encode.
251
256
  """
252
257
  url = self.api_url + path
253
258
  if query:
@@ -260,7 +265,23 @@ class ExperimentationClient:
260
265
  logger.debug("429 from %s %s; retrying once after %.1fs", method, path, wait)
261
266
  self._sleep(wait)
262
267
  response = self._send(method, url, data)
268
+ return response
269
+
270
+ def _request(
271
+ self,
272
+ method: str,
273
+ path: str,
274
+ *,
275
+ body: Optional[Mapping[str, Any]] = None,
276
+ query: Optional[Mapping[str, str]] = None,
277
+ ) -> Any:
278
+ """Perform one API call and return the decoded JSON body (``None`` if empty).
263
279
 
280
+ Retries exactly once on 429, sleeping for ``Retry-After`` (capped at
281
+ :data:`MAX_RETRY_AFTER_SECONDS`). Raises :class:`ExperimentationError`
282
+ for any non-2xx response, network failure or undecodable body.
283
+ """
284
+ response = self._exchange(method, path, body=body, query=query)
264
285
  if response.status < 200 or response.status >= 300:
265
286
  raise ExperimentationError(
266
287
  f"{method} {path} failed with HTTP {response.status}",
@@ -290,8 +311,10 @@ class ExperimentationClient:
290
311
 
291
312
  Sticky on the server and cached here per user + key. ``user_attributes``
292
313
  is sent as ``context`` (used by targeting rules). Raises
293
- :class:`ExperimentationError` (``status == 404`` when the experiment is
294
- not ACTIVE or unknown).
314
+ :class:`ExperimentationError` (``status == 404`` when no experiment has
315
+ the key, or for a draft with no control variant). An experiment that is
316
+ not ACTIVE returns its control variant with ``assigned`` False and
317
+ ``reason`` its status.
295
318
  """
296
319
  cached = self._assignments.get(user_id, experiment_key)
297
320
  if cached is not None:
@@ -300,7 +323,13 @@ class ExperimentationClient:
300
323
  body: Dict[str, Any] = {"experiment_key": experiment_key, "user_id": user_id}
301
324
  if user_attributes is not None:
302
325
  body["context"] = dict(user_attributes)
303
- data = self._request("POST", "/api/v1/tracking/assign", body=body)
326
+ try:
327
+ data = self._request("POST", "/api/v1/tracking/assign", body=body)
328
+ except ExperimentationError as exc:
329
+ # A 404: a key-less track() must no longer reach this experiment.
330
+ if exc.status == 404:
331
+ self._attributed.forget_experiment(user_id, experiment_key)
332
+ raise
304
333
 
305
334
  if not isinstance(data, dict) or not isinstance(data.get("variant_name"), str):
306
335
  raise ExperimentationError(
@@ -322,6 +351,7 @@ class ExperimentationClient:
322
351
  reason=data["reason"] if isinstance(data.get("reason"), str) else None,
323
352
  )
324
353
  self._assignments.set(user_id, experiment_key, assignment)
354
+ self._attributed.record_experiment(user_id, assignment.experiment_key, assignment.reason)
325
355
  return assignment
326
356
 
327
357
  def get_variant(
@@ -381,6 +411,7 @@ class ExperimentationClient:
381
411
  )
382
412
  # Recorded for the key-less track() fan-out, as a server answer is.
383
413
  self._flags.set(user_id, flag_key, local)
414
+ self._attributed.record_flag(user_id, flag_key)
384
415
  return local
385
416
  cached = self._flags.get(user_id, flag_key)
386
417
  if cached is not None:
@@ -410,6 +441,7 @@ class ExperimentationClient:
410
441
  source="server" if self._local is not None else None,
411
442
  )
412
443
  self._flags.set(user_id, flag_key, evaluation)
444
+ self._attributed.record_flag(user_id, flag_key)
413
445
  return evaluation
414
446
 
415
447
  def is_feature_enabled(
@@ -494,13 +526,19 @@ class ExperimentationClient:
494
526
  * With ``experiment_key`` and/or ``feature_flag_key``: one
495
527
  ``POST /api/v1/tracking/track``.
496
528
  * Without a key: one ``POST /api/v1/tracking/batch`` (chunked at 100)
497
- containing one entry per cached assignment (``experiment_key``) plus
498
- one per cached evaluated flag (``feature_flag_key``) for this user.
499
- If nothing is cached for the user, nothing is sent and ``False`` is
500
- returned.
501
-
502
- ``event_type`` defaults to ``event_name``. Returns ``True`` only when
503
- the server accepted the event(s).
529
+ containing one entry per experiment (``experiment_key``) plus one per
530
+ flag (``feature_flag_key``) this client answered for the user in the
531
+ last 24 hours, cached or not. An experiment whose latest answer said it
532
+ is a draft, paused, completed or archived gets no entry.
533
+ * Without a key and with nothing to attribute it to: one
534
+ ``POST /api/v1/tracking/track`` with no key, which the server stores as
535
+ history.
536
+
537
+ ``event_type`` defaults to ``event_name``. Returns ``True`` when the
538
+ event was sent and the server answered 2xx (every request, when a
539
+ fan-out takes more than one); ``False`` otherwise. A 2xx batch can still
540
+ report a failed entry, for example a key the server no longer has; use
541
+ :meth:`track_batch` to read the per-entry results.
504
542
  """
505
543
  try:
506
544
  base = self._event_body(user_id, event_name, event_value, properties, event_type, timestamp)
@@ -513,19 +551,30 @@ class ExperimentationClient:
513
551
  self._request("POST", "/api/v1/tracking/track", body=body)
514
552
  return True
515
553
 
516
- events: List[Dict[str, Any]] = [
517
- dict(base, experiment_key=assignment.experiment_key)
518
- for assignment in self.cached_assignments(user_id)
519
- ]
520
- events.extend(dict(base, feature_flag_key=flag.key) for flag in self.cached_flags(user_id))
554
+ experiments, flags = self._attributed.keys(user_id)
555
+ events: List[Dict[str, Any]] = [dict(base, experiment_key=key) for key in experiments]
556
+ events.extend(dict(base, feature_flag_key=key) for key in flags)
521
557
  if not events:
522
- logger.debug("track(%s, %s): nothing cached for user; nothing sent", user_id, event_name)
523
- return False
524
- return self.track_batch(events).ok
558
+ # Nothing to attribute it to: sent once with no key, stored as history.
559
+ self._request("POST", "/api/v1/tracking/track", body=base)
560
+ return True
561
+ return self._post_batches(events)
525
562
  except Exception as exc: # noqa: BLE001 - fire-and-forget
526
563
  logger.warning("track(%s, %s) failed: %s", user_id, event_name, exc)
527
564
  return False
528
565
 
566
+ def _post_batches(self, events: List[Dict[str, Any]]) -> bool:
567
+ """``POST /api/v1/tracking/batch`` in chunks of 100; ``True`` when every
568
+ chunk was answered 2xx. A failed chunk is logged and the rest are still sent."""
569
+ ok = True
570
+ for start in range(0, len(events), BATCH_LIMIT):
571
+ try:
572
+ self._request("POST", "/api/v1/tracking/batch", body={"events": events[start : start + BATCH_LIMIT]})
573
+ except Exception as exc: # noqa: BLE001 - never raise
574
+ logger.warning("track fan-out chunk starting at %d failed: %s", start, exc)
575
+ ok = False
576
+ return ok
577
+
529
578
  def track_batch(self, events: Iterable[Mapping[str, Any]]) -> BatchResult:
530
579
  """Send raw track bodies via ``POST /api/v1/tracking/batch`` in chunks of 100. Never raises.
531
580
 
@@ -577,10 +626,68 @@ class ExperimentationClient:
577
626
  body["timestamp"] = _iso_timestamp(body["timestamp"])
578
627
  return body
579
628
 
629
+ # ---------------------------------------------------------- error reports
630
+
631
+ def report_error(
632
+ self,
633
+ error_type: str,
634
+ message: str,
635
+ *,
636
+ feature_flag_key: Optional[str] = None,
637
+ experiment_key: Optional[str] = None,
638
+ user_id: Optional[str] = None,
639
+ stack_trace: Optional[str] = None,
640
+ metadata: Optional[Mapping[str, Any]] = None,
641
+ timestamp: Union[datetime, str, None] = None,
642
+ ) -> bool:
643
+ """Report an error that happened while a flag or experiment was active, with one
644
+ ``POST /api/v1/tracking/errors``. Works in both evaluation modes. Never raises.
645
+
646
+ Returns ``True`` exactly when the server answered 201: the report is stored. It
647
+ counts toward the flag's error rate and automatic rollback only when this client's
648
+ key is a server-side key (the ``sdk:ruleset`` scope, created by a user who can change
649
+ flags); the flag's safety check shows the others as ``uncounted_error_count``.
650
+ Returns ``False``, and logs a warning, on any other answer (401, 404, 422, a 429
651
+ after its one retry, 5xx) or a network failure. The server checks the report (it
652
+ needs ``feature_flag_key`` or ``experiment_key``); the SDK does not. ``timestamp``
653
+ (a ``datetime``, naive meaning UTC, or an ISO-8601 string) is kept with the report
654
+ as ``client_timestamp``; the stored row is timed when the server receives it.
655
+ """
656
+ path = "/api/v1/tracking/errors"
657
+ try:
658
+ body: Dict[str, Any] = {"error_type": error_type, "message": message}
659
+ if feature_flag_key is not None:
660
+ body["feature_flag_key"] = feature_flag_key
661
+ if experiment_key is not None:
662
+ body["experiment_key"] = experiment_key
663
+ if user_id is not None:
664
+ body["user_id"] = user_id
665
+ if stack_trace is not None:
666
+ body["stack_trace"] = stack_trace
667
+ if metadata is not None:
668
+ body["metadata"] = dict(metadata)
669
+ stamp = _iso_timestamp(timestamp)
670
+ if stamp is not None:
671
+ body["timestamp"] = stamp
672
+ response = self._exchange("POST", path, body=body)
673
+ except Exception as exc: # noqa: BLE001 - never raise
674
+ logger.warning("report_error(%s) failed: %s", error_type, exc)
675
+ return False
676
+ if response.status == 201:
677
+ return True
678
+ logger.warning(
679
+ "report_error(%s) was not stored: POST %s answered HTTP %d: %s",
680
+ error_type,
681
+ path,
682
+ response.status,
683
+ response.text()[:200],
684
+ )
685
+ return False
686
+
580
687
  # ------------------------------------------------------------------ cache
581
688
 
582
689
  def cached_assignments(self, user_id: str) -> List[Assignment]:
583
- """Successful, unexpired assignments cached for the user (what ``track`` fans out to)."""
690
+ """Successful, unexpired assignments cached for the user."""
584
691
  return [assignment for _, assignment in self._assignments.entries(user_id)]
585
692
 
586
693
  def cached_flags(self, user_id: str) -> List[FlagEvaluation]:
@@ -588,6 +695,7 @@ class ExperimentationClient:
588
695
  return [evaluation for _, evaluation in self._flags.entries(user_id)]
589
696
 
590
697
  def clear_cache(self) -> None:
591
- """Drop every cached assignment and evaluation."""
698
+ """Drop every cached assignment and evaluation, so the next calls ask the
699
+ server again. The keys a key-less ``track()`` is sent to are kept."""
592
700
  self._assignments.clear()
593
701
  self._flags.clear()
@@ -42,7 +42,9 @@ class Assignment:
42
42
  returned the control variant instead, with no exposure recorded. ``reason``
43
43
  says why: ``"assigned"``, ``"holdout"``, ``"mutual_exclusion"`` or
44
44
  ``"targeting"``. Both are ``None`` when the server predates the fields; such
45
- a server cannot tell you, so do not read ``None`` as either answer.
45
+ a server cannot tell you, so do not read ``None`` as either answer. For an
46
+ experiment that is not ACTIVE, ``assigned`` is ``False`` and ``reason`` is its
47
+ status (``"draft"``, ``"paused"``, ``"completed"`` or ``"archived"``).
46
48
  """
47
49
 
48
50
  experiment_key: str
@@ -1,3 +1,3 @@
1
1
  """Package version (kept in its own module so ``client`` can build a User-Agent without import cycles)."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.0"