devcycle-python-server-sdk 2.0.0__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devcycle_python_sdk/VERSION.txt +1 -1
- devcycle_python_sdk/__init__.py +3 -2
- devcycle_python_sdk/api/backoff.py +11 -0
- devcycle_python_sdk/api/bucketing_client.py +17 -25
- devcycle_python_sdk/api/config_client.py +116 -0
- devcycle_python_sdk/api/event_client.py +95 -0
- devcycle_python_sdk/api/local_bucketing.py +436 -0
- devcycle_python_sdk/{dvc_cloud_client.py → cloud_client.py} +69 -41
- devcycle_python_sdk/exceptions.py +25 -3
- devcycle_python_sdk/local_client.py +220 -0
- devcycle_python_sdk/managers/__init__.py +0 -0
- devcycle_python_sdk/managers/config_manager.py +95 -0
- devcycle_python_sdk/managers/event_queue_manager.py +223 -0
- devcycle_python_sdk/models/bucketed_config.py +150 -0
- devcycle_python_sdk/models/event.py +111 -8
- devcycle_python_sdk/models/feature.py +12 -0
- devcycle_python_sdk/models/platform_data.py +33 -0
- devcycle_python_sdk/models/user.py +52 -7
- devcycle_python_sdk/models/variable.py +17 -5
- devcycle_python_sdk/options.py +106 -0
- devcycle_python_sdk/protobuf/__init__.py +0 -0
- devcycle_python_sdk/protobuf/utils.py +129 -0
- devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +46 -0
- devcycle_python_sdk/py.typed +0 -0
- devcycle_python_sdk/util/version.py +1 -1
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/METADATA +3 -2
- devcycle_python_server_sdk-3.0.0.dist-info/RECORD +55 -0
- example/{example.py → cloud_client_example.py} +24 -20
- example/local_bucketing_client_example.py +74 -0
- test/api/__init__.py +0 -0
- test/{test_bucketing_client.py → api/test_bucketing_client.py} +10 -5
- test/api/test_config_client.py +133 -0
- test/api/test_event_client.py +97 -0
- test/api/test_local_bucketing.py +392 -0
- test/fixture/__init__.py +0 -0
- test/fixture/data.py +57 -0
- test/managers/__init__.py +0 -0
- test/managers/test_config_manager.py +132 -0
- test/managers/test_event_queue_manager.py +271 -0
- test/models/__init__.py +0 -0
- test/models/test_bucketed_config.py +26 -0
- test/{test_dvc_cloud_client.py → test_cloud_client.py} +11 -17
- test/test_local_client.py +397 -0
- test/util/__init__.py +0 -0
- test/util/test_utils.py +183 -0
- test/{test_version.py → util/test_version.py} +2 -2
- devcycle_python_sdk/dvc_options.py +0 -18
- devcycle_python_server_sdk-2.0.0.dist-info/RECORD +0 -26
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/LICENSE +0 -0
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/WHEEL +0 -0
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from numbers import Real
|
|
4
|
+
from typing import Any, Dict, Union
|
|
5
|
+
|
|
6
|
+
from devcycle_python_sdk import DevCycleLocalOptions
|
|
7
|
+
from devcycle_python_sdk.api.local_bucketing import LocalBucketing
|
|
8
|
+
from devcycle_python_sdk.exceptions import VariableTypeMismatchError
|
|
9
|
+
from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
|
|
10
|
+
from devcycle_python_sdk.managers.event_queue_manager import EventQueueManager
|
|
11
|
+
from devcycle_python_sdk.models.bucketed_config import BucketedConfig
|
|
12
|
+
from devcycle_python_sdk.models.event import DevCycleEvent, EventType
|
|
13
|
+
from devcycle_python_sdk.models.feature import Feature
|
|
14
|
+
from devcycle_python_sdk.models.platform_data import default_platform_data
|
|
15
|
+
from devcycle_python_sdk.models.user import DevCycleUser
|
|
16
|
+
from devcycle_python_sdk.models.variable import Variable
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DevCycleLocalClient:
|
|
22
|
+
def __init__(self, sdk_key: str, options: DevCycleLocalOptions):
|
|
23
|
+
_validate_sdk_key(sdk_key)
|
|
24
|
+
self._sdk_key = sdk_key
|
|
25
|
+
|
|
26
|
+
if options is None:
|
|
27
|
+
self.options: DevCycleLocalOptions = DevCycleLocalOptions()
|
|
28
|
+
else:
|
|
29
|
+
self.options = options
|
|
30
|
+
|
|
31
|
+
self.local_bucketing = LocalBucketing(sdk_key)
|
|
32
|
+
|
|
33
|
+
self._platform_data = default_platform_data()
|
|
34
|
+
self.local_bucketing.set_platform_data(
|
|
35
|
+
json.dumps(self._platform_data.to_json())
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
self.config_manager: EnvironmentConfigManager = EnvironmentConfigManager(
|
|
39
|
+
sdk_key, self.options, self.local_bucketing
|
|
40
|
+
)
|
|
41
|
+
self.event_queue_manager: EventQueueManager = EventQueueManager(
|
|
42
|
+
sdk_key, self.options, self.local_bucketing
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def is_initialized(self) -> bool:
|
|
46
|
+
return self.config_manager.is_initialized()
|
|
47
|
+
|
|
48
|
+
def set_client_custom_data(
|
|
49
|
+
self, custom_data: Dict[str, Union[str, Real, bool, None]]
|
|
50
|
+
) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Sets global custom data for this client. This data will be utilized in all segmentation and bucketing
|
|
53
|
+
decisions. This data will be merged with any custom data set on the user object, with user data
|
|
54
|
+
taking priority
|
|
55
|
+
|
|
56
|
+
:param custom_data: Global data to set. Supported values are strings, numbers, booleans. Nested dictionaries
|
|
57
|
+
are not permitted
|
|
58
|
+
"""
|
|
59
|
+
if not self.is_initialized():
|
|
60
|
+
logger.debug("set_client_custom_data called before client has initialized")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if custom_data:
|
|
64
|
+
try:
|
|
65
|
+
custom_data_json = json.dumps(custom_data)
|
|
66
|
+
self.local_bucketing.set_client_custom_data(custom_data_json)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.error("Error setting custom data: " + str(e))
|
|
69
|
+
|
|
70
|
+
def variable_value(self, user: DevCycleUser, key: str, default_value: Any) -> Any:
|
|
71
|
+
"""
|
|
72
|
+
Evaluates a variable for a user and returns the value. If the user is not bucketed into the variable, the default value will be returned
|
|
73
|
+
|
|
74
|
+
:param user: The user to evaluate the variable for
|
|
75
|
+
"""
|
|
76
|
+
return self.variable(user, key, default_value).value
|
|
77
|
+
|
|
78
|
+
def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable:
|
|
79
|
+
"""
|
|
80
|
+
Evaluates a variable for a user.
|
|
81
|
+
|
|
82
|
+
:param user: The user to evaluate the variable for
|
|
83
|
+
:param key: The key of the variable to evaluate
|
|
84
|
+
:param default_value: The default value to return if the user is not bucketed into the variable
|
|
85
|
+
"""
|
|
86
|
+
_validate_user(user)
|
|
87
|
+
|
|
88
|
+
if not key:
|
|
89
|
+
raise ValueError("Missing parameter: key")
|
|
90
|
+
|
|
91
|
+
if default_value is None:
|
|
92
|
+
raise ValueError("Missing parameter: defaultValue")
|
|
93
|
+
|
|
94
|
+
if not self.is_initialized():
|
|
95
|
+
logger.debug("variable called before client has initialized")
|
|
96
|
+
try:
|
|
97
|
+
self.event_queue_manager.queue_aggregate_event(
|
|
98
|
+
event=DevCycleEvent(
|
|
99
|
+
type=EventType.AggVariableDefaulted, target=key, value=1
|
|
100
|
+
),
|
|
101
|
+
bucketed_config=None,
|
|
102
|
+
)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning(
|
|
105
|
+
f"Unable to track AggVariableDefaulted event for Variable {key}: {e}"
|
|
106
|
+
)
|
|
107
|
+
return Variable.create_default_variable(key, default_value)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
variable = self.local_bucketing.get_variable_for_user_protobuf(
|
|
111
|
+
user, key, default_value
|
|
112
|
+
)
|
|
113
|
+
if variable:
|
|
114
|
+
return variable
|
|
115
|
+
except VariableTypeMismatchError:
|
|
116
|
+
logger.debug("Variable type mismatch, returning default value")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.warning(f"Error retrieving variable for user: {e}")
|
|
119
|
+
|
|
120
|
+
return Variable.create_default_variable(key, default_value)
|
|
121
|
+
|
|
122
|
+
def _generate_bucketed_config(self, user: DevCycleUser) -> BucketedConfig:
|
|
123
|
+
"""
|
|
124
|
+
Generates a bucketed config for a user. This method will return an empty config if the client has not been initialized or if the user is not bucketed into any features or variables
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
_validate_user(user)
|
|
128
|
+
|
|
129
|
+
return self.local_bucketing.generate_bucketed_config(user)
|
|
130
|
+
|
|
131
|
+
def all_variables(self, user: DevCycleUser) -> Dict[str, Variable]:
|
|
132
|
+
"""
|
|
133
|
+
Returns all segmented and bucketed variables for a user. This method will return an empty map if the client has not been initialized or if the user is not bucketed into any variables
|
|
134
|
+
|
|
135
|
+
:param user: The user to retrieve variables for
|
|
136
|
+
"""
|
|
137
|
+
_validate_user(user)
|
|
138
|
+
|
|
139
|
+
if not self.is_initialized():
|
|
140
|
+
logger.warning("all_variables called before client has initialized")
|
|
141
|
+
return {}
|
|
142
|
+
|
|
143
|
+
variable_map: Dict[str, Variable] = {}
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
return self.local_bucketing.generate_bucketed_config(user).variables
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.exception(f"Error retrieving all variables for a user: {e}")
|
|
149
|
+
return {}
|
|
150
|
+
|
|
151
|
+
return variable_map
|
|
152
|
+
|
|
153
|
+
def all_features(self, user: DevCycleUser) -> Dict[str, Feature]:
|
|
154
|
+
"""
|
|
155
|
+
Returns all segmented and bucketed features for a user. This method will return an empty map if the client has not been initialized or if the user is not bucketed into any features
|
|
156
|
+
|
|
157
|
+
:param user: The user to retrieve features for
|
|
158
|
+
"""
|
|
159
|
+
_validate_user(user)
|
|
160
|
+
|
|
161
|
+
if not self.is_initialized():
|
|
162
|
+
logger.warning("all_features called before client has initialized")
|
|
163
|
+
return {}
|
|
164
|
+
|
|
165
|
+
feature_map: Dict[str, Feature] = {}
|
|
166
|
+
try:
|
|
167
|
+
return self.local_bucketing.generate_bucketed_config(user).features
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.exception(f"Error retrieving all features for a user: {e}")
|
|
170
|
+
|
|
171
|
+
return feature_map
|
|
172
|
+
|
|
173
|
+
def track(self, user: DevCycleUser, user_event: DevCycleEvent) -> None:
|
|
174
|
+
"""
|
|
175
|
+
Tracks a custom event for a user. This method will return immediately and the event will be queued for processing in the background. If the client has not been initialized, this method will return immediately and the event will be discarded.
|
|
176
|
+
|
|
177
|
+
:param user: The user to track the event for
|
|
178
|
+
:param user_event: The event to track
|
|
179
|
+
"""
|
|
180
|
+
_validate_user(user)
|
|
181
|
+
|
|
182
|
+
if user_event is None:
|
|
183
|
+
raise ValueError("Invalid Event")
|
|
184
|
+
|
|
185
|
+
if user_event.type is None or len(user_event.type) == 0:
|
|
186
|
+
raise ValueError("Missing parameter: type")
|
|
187
|
+
|
|
188
|
+
if not self.is_initialized():
|
|
189
|
+
logger.debug("track called before client has initialized")
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
self.event_queue_manager.queue_event(user, user_event)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.error(f"Error tracking event: {e}")
|
|
196
|
+
|
|
197
|
+
def close(self) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Closes the client and releases any resources held by it.
|
|
200
|
+
"""
|
|
201
|
+
self.config_manager.close()
|
|
202
|
+
self.event_queue_manager.close()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _validate_sdk_key(sdk_key: str) -> None:
|
|
206
|
+
if sdk_key is None or len(sdk_key) == 0:
|
|
207
|
+
raise ValueError("Missing SDK key! Call initialize with a valid SDK key")
|
|
208
|
+
|
|
209
|
+
if not sdk_key.startswith("server") and not sdk_key.startswith("dvc_server"):
|
|
210
|
+
raise ValueError(
|
|
211
|
+
"Invalid SDK key provided. Please call initialize with a valid server SDK key"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _validate_user(user: DevCycleUser) -> None:
|
|
216
|
+
if user is None:
|
|
217
|
+
raise ValueError("User cannot be None")
|
|
218
|
+
|
|
219
|
+
if user.user_id is None or len(user.user_id) == 0:
|
|
220
|
+
raise ValueError("userId cannot be empty")
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
4
|
+
import json
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from devcycle_python_sdk.options import DevCycleLocalOptions
|
|
8
|
+
from devcycle_python_sdk.api.local_bucketing import LocalBucketing
|
|
9
|
+
from devcycle_python_sdk.api.config_client import ConfigAPIClient
|
|
10
|
+
from devcycle_python_sdk.exceptions import (
|
|
11
|
+
CloudClientUnauthorizedError,
|
|
12
|
+
CloudClientError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EnvironmentConfigManager(threading.Thread):
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
sdk_key: str,
|
|
22
|
+
options: DevCycleLocalOptions,
|
|
23
|
+
local_bucketing: LocalBucketing,
|
|
24
|
+
):
|
|
25
|
+
super().__init__()
|
|
26
|
+
|
|
27
|
+
self._sdk_key = sdk_key
|
|
28
|
+
self._options = options
|
|
29
|
+
self._local_bucketing = local_bucketing
|
|
30
|
+
|
|
31
|
+
self._config: Optional[dict] = None
|
|
32
|
+
self._config_etag: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
self._config_api_client = ConfigAPIClient(self._sdk_key, self._options)
|
|
35
|
+
|
|
36
|
+
self._polling_enabled = True
|
|
37
|
+
self.daemon = True
|
|
38
|
+
self.start()
|
|
39
|
+
|
|
40
|
+
def is_initialized(self) -> bool:
|
|
41
|
+
return self._config is not None
|
|
42
|
+
|
|
43
|
+
def _get_config(self):
|
|
44
|
+
try:
|
|
45
|
+
new_config, new_etag = self._config_api_client.get_config(
|
|
46
|
+
config_etag=self._config_etag
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if new_config is None and new_etag == self._config_etag:
|
|
50
|
+
# api not returning data and the etag is the same
|
|
51
|
+
# no change to the config since last request
|
|
52
|
+
return
|
|
53
|
+
elif new_config is None:
|
|
54
|
+
logger.warning(
|
|
55
|
+
"Config CDN fetch returned no data with a different etag"
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
trigger_on_client_initialized = self._config is None
|
|
60
|
+
|
|
61
|
+
self._config = new_config
|
|
62
|
+
self._config_etag = new_etag
|
|
63
|
+
|
|
64
|
+
json_config = json.dumps(self._config)
|
|
65
|
+
self._local_bucketing.store_config(json_config)
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
trigger_on_client_initialized
|
|
69
|
+
and self._options.on_client_initialized is not None
|
|
70
|
+
):
|
|
71
|
+
try:
|
|
72
|
+
self._options.on_client_initialized()
|
|
73
|
+
except Exception as e:
|
|
74
|
+
# consume any error
|
|
75
|
+
logger.warning(
|
|
76
|
+
f"Error received from on_client_initialized callback: {str(e)}"
|
|
77
|
+
)
|
|
78
|
+
except CloudClientError as e:
|
|
79
|
+
logger.warning(f"Config fetch failed. Status: {str(e)}")
|
|
80
|
+
except CloudClientUnauthorizedError:
|
|
81
|
+
logger.error("Unauthorized to get config. Aborting config polling.")
|
|
82
|
+
self._polling_enabled = False
|
|
83
|
+
|
|
84
|
+
def run(self):
|
|
85
|
+
while self._polling_enabled:
|
|
86
|
+
try:
|
|
87
|
+
self._get_config()
|
|
88
|
+
except Exception as e:
|
|
89
|
+
if self._polling_enabled:
|
|
90
|
+
# Only log a warning if we're still polling
|
|
91
|
+
logger.warning(f"Error polling for config changes: {str(e)}")
|
|
92
|
+
time.sleep(self._options.config_polling_interval_ms / 1000.0)
|
|
93
|
+
|
|
94
|
+
def close(self):
|
|
95
|
+
self._polling_enabled = False
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import logging
|
|
3
|
+
import json
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from devcycle_python_sdk.options import DevCycleLocalOptions
|
|
7
|
+
from devcycle_python_sdk.api.local_bucketing import LocalBucketing
|
|
8
|
+
from devcycle_python_sdk.api.event_client import EventAPIClient
|
|
9
|
+
from devcycle_python_sdk.exceptions import (
|
|
10
|
+
APIClientError,
|
|
11
|
+
APIClientUnauthorizedError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
)
|
|
14
|
+
from devcycle_python_sdk.models.event import (
|
|
15
|
+
FlushPayload,
|
|
16
|
+
DevCycleEvent,
|
|
17
|
+
EventType,
|
|
18
|
+
)
|
|
19
|
+
from devcycle_python_sdk.models.user import DevCycleUser
|
|
20
|
+
from devcycle_python_sdk.models.bucketed_config import BucketedConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QueueFullError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EventQueueManager(threading.Thread):
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
sdk_key: str,
|
|
34
|
+
options: DevCycleLocalOptions,
|
|
35
|
+
local_bucketing: LocalBucketing,
|
|
36
|
+
):
|
|
37
|
+
super().__init__()
|
|
38
|
+
|
|
39
|
+
if sdk_key is None or sdk_key == "":
|
|
40
|
+
raise ValueError("DevCycle is not yet initialized to publish events.")
|
|
41
|
+
|
|
42
|
+
self._sdk_key = sdk_key
|
|
43
|
+
self._options = options
|
|
44
|
+
self._local_bucketing = local_bucketing
|
|
45
|
+
self._event_api_client = EventAPIClient(self._sdk_key, self._options)
|
|
46
|
+
self._flush_lock = threading.Lock()
|
|
47
|
+
self._exit = threading.Event()
|
|
48
|
+
self._exited = threading.Event()
|
|
49
|
+
|
|
50
|
+
# Setup the event queue inside the WASM module
|
|
51
|
+
event_options_json = json.dumps(self._options.event_queue_options())
|
|
52
|
+
self._local_bucketing.init_event_queue(event_options_json)
|
|
53
|
+
|
|
54
|
+
# Only start event processing thread if event logging is enabled
|
|
55
|
+
if not (
|
|
56
|
+
self._options.disable_custom_event_logging
|
|
57
|
+
and self._options.disable_automatic_event_logging
|
|
58
|
+
):
|
|
59
|
+
self.daemon = True
|
|
60
|
+
self.start()
|
|
61
|
+
else:
|
|
62
|
+
self._stop_running()
|
|
63
|
+
self._mark_exited()
|
|
64
|
+
|
|
65
|
+
def _should_run(self) -> bool:
|
|
66
|
+
# Returns true if the thread should continue running, false otherwise
|
|
67
|
+
return not self._exit.is_set()
|
|
68
|
+
|
|
69
|
+
def _stop_running(self) -> None:
|
|
70
|
+
# Indicate to the thread that it should stop running, interrupting any sleep
|
|
71
|
+
self._exit.set()
|
|
72
|
+
|
|
73
|
+
def _sleep(self) -> bool:
|
|
74
|
+
# Returns true if the sleep was interrupted, false otherwise
|
|
75
|
+
return self._exit.wait(self._options.event_flush_interval_ms / 1000.0)
|
|
76
|
+
|
|
77
|
+
def _mark_exited(self) -> None:
|
|
78
|
+
# Indicate to the thread calling close that the thread has exited
|
|
79
|
+
self._exited.set()
|
|
80
|
+
|
|
81
|
+
def _wait_for_exit(self, timeout_seconds: float) -> bool:
|
|
82
|
+
# Wait up to timeout_seconds for the thread to exit
|
|
83
|
+
# This works whether the thread was actually started or not
|
|
84
|
+
return self._exited.wait(timeout_seconds)
|
|
85
|
+
|
|
86
|
+
def _flush_events(self) -> int:
|
|
87
|
+
if self._flush_lock.locked():
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
with self._flush_lock:
|
|
91
|
+
try:
|
|
92
|
+
payloads = self._local_bucketing.flush_event_queue()
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error(f"Error flushing event payloads: {str(e)}")
|
|
95
|
+
|
|
96
|
+
event_count = 0
|
|
97
|
+
if payloads:
|
|
98
|
+
logger.debug(f"Flush {len(payloads)} event payloads")
|
|
99
|
+
for payload in payloads:
|
|
100
|
+
event_count += payload.eventCount
|
|
101
|
+
self._publish_event_payload(payload)
|
|
102
|
+
logger.debug(f"Flush {event_count} events, for {len(payloads)} users")
|
|
103
|
+
return event_count
|
|
104
|
+
|
|
105
|
+
def _publish_event_payload(self, payload: FlushPayload) -> None:
|
|
106
|
+
if payload and payload.records:
|
|
107
|
+
try:
|
|
108
|
+
self._event_api_client.publish_events(payload.records)
|
|
109
|
+
self._local_bucketing.on_event_payload_success(payload.payloadId)
|
|
110
|
+
except APIClientUnauthorizedError:
|
|
111
|
+
logger.error(
|
|
112
|
+
"Unauthorized to publish events, please check your SDK key"
|
|
113
|
+
)
|
|
114
|
+
# stop the thread
|
|
115
|
+
self._stop_running()
|
|
116
|
+
self._local_bucketing.on_event_payload_failure(payload.payloadId, False)
|
|
117
|
+
except NotFoundError as e:
|
|
118
|
+
logger.error(
|
|
119
|
+
f"Unable to reach the DevCycle Events API service: {str(e)}"
|
|
120
|
+
)
|
|
121
|
+
self._stop_running()
|
|
122
|
+
self._local_bucketing.on_event_payload_failure(payload.payloadId, False)
|
|
123
|
+
except APIClientError as e:
|
|
124
|
+
logger.warning(
|
|
125
|
+
f"Error publishing events to DevCycle Events API service: {str(e)}"
|
|
126
|
+
)
|
|
127
|
+
self._local_bucketing.on_event_payload_failure(payload.payloadId, True)
|
|
128
|
+
|
|
129
|
+
def is_event_logging_disabled(self, event_type: str) -> bool:
|
|
130
|
+
if event_type in [
|
|
131
|
+
EventType.VariableDefaulted,
|
|
132
|
+
EventType.VariableEvaluated,
|
|
133
|
+
EventType.AggVariableDefaulted,
|
|
134
|
+
EventType.AggVariableEvaluated,
|
|
135
|
+
]:
|
|
136
|
+
return self._options.disable_automatic_event_logging
|
|
137
|
+
else:
|
|
138
|
+
return self._options.disable_custom_event_logging
|
|
139
|
+
|
|
140
|
+
def run(self):
|
|
141
|
+
while self._should_run():
|
|
142
|
+
try:
|
|
143
|
+
self._flush_events()
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.warning(f"Error flushing events: {str(e)}")
|
|
146
|
+
|
|
147
|
+
self._sleep()
|
|
148
|
+
|
|
149
|
+
self._mark_exited()
|
|
150
|
+
|
|
151
|
+
def close(self):
|
|
152
|
+
self._stop_running()
|
|
153
|
+
|
|
154
|
+
# Wait up to 1s for the thread to exit.
|
|
155
|
+
# Because the sleeping between batches is interruptible, this is only
|
|
156
|
+
# providing time for an in-flight batch to finish.
|
|
157
|
+
if not self._wait_for_exit(1.0):
|
|
158
|
+
logger.error("Timed out waiting for event flushing thread to stop")
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
self._flush_events()
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.warning(f"DVC Error flushing events when closing client: {str(e)}")
|
|
164
|
+
|
|
165
|
+
def queue_event(self, user: DevCycleUser, event: DevCycleEvent) -> None:
|
|
166
|
+
if user is None:
|
|
167
|
+
raise ValueError("user cannot be None")
|
|
168
|
+
|
|
169
|
+
if event is None:
|
|
170
|
+
raise ValueError("event cannot be None")
|
|
171
|
+
|
|
172
|
+
if not event.type:
|
|
173
|
+
raise ValueError("event type not set")
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
self._check_queue_status()
|
|
177
|
+
except QueueFullError:
|
|
178
|
+
logger.warning("Event queue is full, dropping user event")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
user_json = json.dumps(user.to_json())
|
|
182
|
+
event_json = json.dumps(event.to_json())
|
|
183
|
+
self._local_bucketing.queue_event(user_json, event_json)
|
|
184
|
+
|
|
185
|
+
def queue_aggregate_event(
|
|
186
|
+
self, event: DevCycleEvent, bucketed_config: Optional[BucketedConfig]
|
|
187
|
+
) -> None:
|
|
188
|
+
if event is None:
|
|
189
|
+
raise ValueError("event cannot be None")
|
|
190
|
+
|
|
191
|
+
if not event.type:
|
|
192
|
+
raise ValueError("event type not set")
|
|
193
|
+
|
|
194
|
+
if not event.target:
|
|
195
|
+
raise ValueError("event target not set")
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
self._check_queue_status()
|
|
199
|
+
except QueueFullError:
|
|
200
|
+
logger.warning("Event queue is full, dropping aggregate event")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
event_json = json.dumps(event.to_json())
|
|
204
|
+
if bucketed_config and bucketed_config.variable_variation_map:
|
|
205
|
+
variation_map_json = json.dumps(bucketed_config.variable_variation_map)
|
|
206
|
+
else:
|
|
207
|
+
variation_map_json = "{}"
|
|
208
|
+
self._local_bucketing.queue_aggregate_event(event_json, variation_map_json)
|
|
209
|
+
|
|
210
|
+
def _check_queue_status(self) -> None:
|
|
211
|
+
if self._flush_needed():
|
|
212
|
+
self._flush_events()
|
|
213
|
+
|
|
214
|
+
if self._queue_full():
|
|
215
|
+
raise QueueFullError()
|
|
216
|
+
|
|
217
|
+
def _flush_needed(self) -> bool:
|
|
218
|
+
queue_size = self._local_bucketing.get_event_queue_size()
|
|
219
|
+
return queue_size >= self._options.flush_event_queue_size
|
|
220
|
+
|
|
221
|
+
def _queue_full(self) -> bool:
|
|
222
|
+
queue_size = self._local_bucketing.get_event_queue_size()
|
|
223
|
+
return queue_size >= self._options.max_event_queue_size
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# ruff: noqa: N815
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from .user import DevCycleUser
|
|
7
|
+
from .variable import Variable
|
|
8
|
+
from .feature import Feature
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class EdgeDBSettings:
|
|
13
|
+
enabled: bool
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_json(cls, data: dict) -> "EdgeDBSettings":
|
|
17
|
+
return cls(
|
|
18
|
+
enabled=data.get("enabled", False),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class OptInColors:
|
|
24
|
+
primary: str
|
|
25
|
+
secondary: str
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_json(cls, data: dict) -> "OptInColors":
|
|
29
|
+
return cls(
|
|
30
|
+
primary=data.get("primary", ""),
|
|
31
|
+
secondary=data.get("secondary", ""),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class OptInSettings:
|
|
37
|
+
enabled: bool
|
|
38
|
+
title: str
|
|
39
|
+
description: str
|
|
40
|
+
image_url: str
|
|
41
|
+
colors: OptInColors
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_json(cls, data: dict) -> "OptInSettings":
|
|
45
|
+
return cls(
|
|
46
|
+
enabled=data.get("enabled", False),
|
|
47
|
+
title=data.get("title", ""),
|
|
48
|
+
description=data.get("description", ""),
|
|
49
|
+
image_url=data.get("imageURL", ""),
|
|
50
|
+
colors=OptInColors.from_json(data.get("colors", "")),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ProjectSettings:
|
|
56
|
+
edge_db: Optional[EdgeDBSettings]
|
|
57
|
+
opt_in: Optional[OptInSettings]
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_json(cls, data: dict) -> "ProjectSettings":
|
|
61
|
+
return cls(
|
|
62
|
+
edge_db=EdgeDBSettings.from_json(data["edgeDB"])
|
|
63
|
+
if "edgeDB" in data
|
|
64
|
+
else None,
|
|
65
|
+
opt_in=OptInSettings.from_json(data["optIn"]) if "optIn" in data else None,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Project:
|
|
71
|
+
id: str
|
|
72
|
+
key: str
|
|
73
|
+
a0_organization: str
|
|
74
|
+
settings: Optional[ProjectSettings]
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_json(cls, data: dict) -> "Project":
|
|
78
|
+
return cls(
|
|
79
|
+
id=data["_id"],
|
|
80
|
+
key=data["key"],
|
|
81
|
+
a0_organization=data["a0_organization"],
|
|
82
|
+
settings=ProjectSettings.from_json(data["settings"])
|
|
83
|
+
if "settings" in data
|
|
84
|
+
else None,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Environment:
|
|
90
|
+
id: str
|
|
91
|
+
key: str
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_json(cls, data: dict) -> "Environment":
|
|
95
|
+
return cls(
|
|
96
|
+
id=data["_id"],
|
|
97
|
+
key=data["key"],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class FeatureVariation:
|
|
103
|
+
feature: str
|
|
104
|
+
variation: str
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_json(cls, data: dict) -> "FeatureVariation":
|
|
108
|
+
return cls(
|
|
109
|
+
feature=data["_feature"],
|
|
110
|
+
variation=data["_variation"],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(order=False)
|
|
115
|
+
class BucketedConfig:
|
|
116
|
+
project: Project
|
|
117
|
+
environment: Environment
|
|
118
|
+
features: Dict[str, Feature]
|
|
119
|
+
feature_variation_map: Dict[str, str]
|
|
120
|
+
variable_variation_map: Dict[str, FeatureVariation]
|
|
121
|
+
variables: Dict[str, Variable]
|
|
122
|
+
known_variable_keys: List[float]
|
|
123
|
+
|
|
124
|
+
user: Optional[DevCycleUser] = None
|
|
125
|
+
|
|
126
|
+
def to_json(self):
|
|
127
|
+
return {
|
|
128
|
+
key: getattr(self, key)
|
|
129
|
+
for key in self.__dataclass_fields__
|
|
130
|
+
if getattr(self, key) is not None
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_json(cls, data: dict) -> "BucketedConfig":
|
|
135
|
+
return cls(
|
|
136
|
+
project=Project.from_json(data["project"]),
|
|
137
|
+
environment=Environment.from_json(data["environment"]),
|
|
138
|
+
features={
|
|
139
|
+
k: Feature.from_json(v) for k, v in data.get("features", {}).items()
|
|
140
|
+
},
|
|
141
|
+
feature_variation_map=data.get("featureVariationMap", {}),
|
|
142
|
+
variable_variation_map={
|
|
143
|
+
k: FeatureVariation.from_json(v)
|
|
144
|
+
for k, v in data.get("variableVariationMap", {}).items()
|
|
145
|
+
},
|
|
146
|
+
variables={
|
|
147
|
+
k: Variable.from_json(v) for k, v in data.get("variables", {}).items()
|
|
148
|
+
},
|
|
149
|
+
known_variable_keys=data.get("knownVariableKeys", []),
|
|
150
|
+
)
|