growthbook 1.2.1__py2.py3-none-any.whl → 1.3.1__py2.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.
- growthbook/__init__.py +22 -1
- growthbook/common_types.py +1 -2
- growthbook/core.py +13 -4
- growthbook/growthbook.py +99 -7
- growthbook/growthbook_client.py +33 -17
- growthbook/plugins/__init__.py +16 -0
- growthbook/plugins/base.py +103 -0
- growthbook/plugins/growthbook_tracking.py +262 -0
- growthbook/plugins/request_context.py +341 -0
- {growthbook-1.2.1.dist-info → growthbook-1.3.1.dist-info}/METADATA +39 -7
- growthbook-1.3.1.dist-info/RECORD +15 -0
- {growthbook-1.2.1.dist-info → growthbook-1.3.1.dist-info}/WHEEL +1 -1
- growthbook-1.2.1.dist-info/RECORD +0 -11
- {growthbook-1.2.1.dist-info → growthbook-1.3.1.dist-info/licenses}/LICENSE +0 -0
- {growthbook-1.2.1.dist-info → growthbook-1.3.1.dist-info}/top_level.txt +0 -0
growthbook/__init__.py
CHANGED
|
@@ -1 +1,22 @@
|
|
|
1
|
-
from .growthbook import *
|
|
1
|
+
from .growthbook import *
|
|
2
|
+
|
|
3
|
+
from .growthbook_client import (
|
|
4
|
+
GrowthBookClient,
|
|
5
|
+
EnhancedFeatureRepository,
|
|
6
|
+
FeatureCache,
|
|
7
|
+
BackoffStrategy
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
# Plugin support
|
|
11
|
+
from .plugins import (
|
|
12
|
+
GrowthBookTrackingPlugin,
|
|
13
|
+
growthbook_tracking_plugin,
|
|
14
|
+
RequestContextPlugin,
|
|
15
|
+
ClientSideAttributes,
|
|
16
|
+
request_context_plugin,
|
|
17
|
+
client_side_attributes
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# x-release-please-start-version
|
|
21
|
+
__version__ = "1.3.1"
|
|
22
|
+
# x-release-please-end
|
growthbook/common_types.py
CHANGED
|
@@ -220,9 +220,8 @@ class FeatureResult(object):
|
|
|
220
220
|
"source": self.source,
|
|
221
221
|
"on": self.on,
|
|
222
222
|
"off": self.off,
|
|
223
|
+
"ruleId": self.ruleId or "",
|
|
223
224
|
}
|
|
224
|
-
if self.ruleId:
|
|
225
|
-
data["ruleId"] = self.ruleId
|
|
226
225
|
if self.experiment:
|
|
227
226
|
data["experiment"] = self.experiment.to_dict()
|
|
228
227
|
if self.experimentResult:
|
growthbook/core.py
CHANGED
|
@@ -413,7 +413,8 @@ def getBucketRanges(
|
|
|
413
413
|
def eval_feature(
|
|
414
414
|
key: str,
|
|
415
415
|
evalContext: EvaluationContext = None,
|
|
416
|
-
callback_subscription: Callable[[Experiment, Result], None] = None
|
|
416
|
+
callback_subscription: Callable[[Experiment, Result], None] = None,
|
|
417
|
+
tracking_cb: Callable[[Experiment, Result], None] = None
|
|
417
418
|
) -> FeatureResult:
|
|
418
419
|
"""Core feature evaluation logic as a standalone function"""
|
|
419
420
|
|
|
@@ -506,7 +507,7 @@ def eval_feature(
|
|
|
506
507
|
minBucketVersion=rule.minBucketVersion,
|
|
507
508
|
)
|
|
508
509
|
|
|
509
|
-
result = run_experiment(experiment=exp, featureId=key, evalContext=evalContext)
|
|
510
|
+
result = run_experiment(experiment=exp, featureId=key, evalContext=evalContext, tracking_cb=tracking_cb)
|
|
510
511
|
|
|
511
512
|
if callback_subscription:
|
|
512
513
|
callback_subscription(exp, result)
|
|
@@ -536,12 +537,20 @@ def eval_prereqs(parentConditions: List[dict], evalContext: EvaluationContext) -
|
|
|
536
537
|
# Reset the stack in each iteration
|
|
537
538
|
evalContext.stack.evaluated_features = evaluated_features.copy()
|
|
538
539
|
|
|
539
|
-
|
|
540
|
+
parent_id = parentCondition.get("id")
|
|
541
|
+
if parent_id is None:
|
|
542
|
+
continue # Skip if no valid ID
|
|
543
|
+
|
|
544
|
+
parentRes = eval_feature(key=parent_id, evalContext=evalContext)
|
|
540
545
|
|
|
541
546
|
if parentRes.source == "cyclicPrerequisite":
|
|
542
547
|
return "cyclic"
|
|
543
548
|
|
|
544
|
-
|
|
549
|
+
parent_condition = parentCondition.get("condition")
|
|
550
|
+
if parent_condition is None:
|
|
551
|
+
continue # Skip if no valid condition
|
|
552
|
+
|
|
553
|
+
if not evalCondition({'value': parentRes.value}, parent_condition, evalContext.global_ctx.saved_groups):
|
|
545
554
|
if parentCondition.get("gate", False):
|
|
546
555
|
return "gate"
|
|
547
556
|
return "fail"
|
growthbook/growthbook.py
CHANGED
|
@@ -11,7 +11,7 @@ import threading
|
|
|
11
11
|
import logging
|
|
12
12
|
|
|
13
13
|
from abc import ABC, abstractmethod
|
|
14
|
-
from typing import Optional, Any, Set, Tuple, List, Dict
|
|
14
|
+
from typing import Optional, Any, Set, Tuple, List, Dict, Callable
|
|
15
15
|
|
|
16
16
|
from .common_types import ( EvaluationContext,
|
|
17
17
|
Experiment,
|
|
@@ -251,6 +251,7 @@ class FeatureRepository(object):
|
|
|
251
251
|
self.cache: AbstractFeatureCache = InMemoryFeatureCache()
|
|
252
252
|
self.http: Optional[PoolManager] = None
|
|
253
253
|
self.sse_client: Optional[SSEClient] = None
|
|
254
|
+
self._feature_update_callbacks: List[Callable[[Dict], None]] = []
|
|
254
255
|
|
|
255
256
|
def set_cache(self, cache: AbstractFeatureCache) -> None:
|
|
256
257
|
self.cache = cache
|
|
@@ -258,12 +259,30 @@ class FeatureRepository(object):
|
|
|
258
259
|
def clear_cache(self):
|
|
259
260
|
self.cache.clear()
|
|
260
261
|
|
|
261
|
-
def save_in_cache(self, key: str, res, ttl: int =
|
|
262
|
+
def save_in_cache(self, key: str, res, ttl: int = 600):
|
|
262
263
|
self.cache.set(key, res, ttl)
|
|
263
264
|
|
|
264
|
-
|
|
265
|
+
def add_feature_update_callback(self, callback: Callable[[Dict], None]) -> None:
|
|
266
|
+
"""Add a callback to be notified when features are updated due to cache expiry"""
|
|
267
|
+
if callback not in self._feature_update_callbacks:
|
|
268
|
+
self._feature_update_callbacks.append(callback)
|
|
269
|
+
|
|
270
|
+
def remove_feature_update_callback(self, callback: Callable[[Dict], None]) -> None:
|
|
271
|
+
"""Remove a feature update callback"""
|
|
272
|
+
if callback in self._feature_update_callbacks:
|
|
273
|
+
self._feature_update_callbacks.remove(callback)
|
|
274
|
+
|
|
275
|
+
def _notify_feature_update_callbacks(self, features_data: Dict) -> None:
|
|
276
|
+
"""Notify all registered callbacks about feature updates"""
|
|
277
|
+
for callback in self._feature_update_callbacks:
|
|
278
|
+
try:
|
|
279
|
+
callback(features_data)
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.warning(f"Error in feature update callback: {e}")
|
|
282
|
+
|
|
283
|
+
# Loads features with an in-memory cache in front using stale-while-revalidate approach
|
|
265
284
|
def load_features(
|
|
266
|
-
self, api_host: str, client_key: str, decryption_key: str = "", ttl: int =
|
|
285
|
+
self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600
|
|
267
286
|
) -> Optional[Dict]:
|
|
268
287
|
if not client_key:
|
|
269
288
|
raise ValueError("Must specify `client_key` to refresh features")
|
|
@@ -276,11 +295,13 @@ class FeatureRepository(object):
|
|
|
276
295
|
if res is not None:
|
|
277
296
|
self.cache.set(key, res, ttl)
|
|
278
297
|
logger.debug("Fetched features from API, stored in cache")
|
|
298
|
+
# Notify callbacks about fresh features
|
|
299
|
+
self._notify_feature_update_callbacks(res)
|
|
279
300
|
return res
|
|
280
301
|
return cached
|
|
281
302
|
|
|
282
303
|
async def load_features_async(
|
|
283
|
-
self, api_host: str, client_key: str, decryption_key: str = "", ttl: int =
|
|
304
|
+
self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600
|
|
284
305
|
) -> Optional[Dict]:
|
|
285
306
|
key = api_host + "::" + client_key
|
|
286
307
|
|
|
@@ -290,6 +311,8 @@ class FeatureRepository(object):
|
|
|
290
311
|
if res is not None:
|
|
291
312
|
self.cache.set(key, res, ttl)
|
|
292
313
|
logger.debug("Fetched features from API, stored in cache")
|
|
314
|
+
# Notify callbacks about fresh features
|
|
315
|
+
self._notify_feature_update_callbacks(res)
|
|
293
316
|
return res
|
|
294
317
|
return cached
|
|
295
318
|
|
|
@@ -414,12 +437,13 @@ class GrowthBook(object):
|
|
|
414
437
|
api_host: str = "",
|
|
415
438
|
client_key: str = "",
|
|
416
439
|
decryption_key: str = "",
|
|
417
|
-
cache_ttl: int =
|
|
440
|
+
cache_ttl: int = 600,
|
|
418
441
|
forced_variations: dict = {},
|
|
419
442
|
sticky_bucket_service: AbstractStickyBucketService = None,
|
|
420
443
|
sticky_bucket_identifier_attributes: List[str] = None,
|
|
421
444
|
savedGroups: dict = {},
|
|
422
445
|
streaming: bool = False,
|
|
446
|
+
plugins: List = None,
|
|
423
447
|
# Deprecated args
|
|
424
448
|
trackingCallback=None,
|
|
425
449
|
qaMode: bool = False,
|
|
@@ -458,6 +482,10 @@ class GrowthBook(object):
|
|
|
458
482
|
self._assigned: Dict[str, Any] = {}
|
|
459
483
|
self._subscriptions: Set[Any] = set()
|
|
460
484
|
|
|
485
|
+
# support plugins
|
|
486
|
+
self._plugins: List = plugins or []
|
|
487
|
+
self._initialized_plugins: List = []
|
|
488
|
+
|
|
461
489
|
self._global_ctx = GlobalContext(
|
|
462
490
|
options=Options(
|
|
463
491
|
url=self._url,
|
|
@@ -486,10 +514,22 @@ class GrowthBook(object):
|
|
|
486
514
|
if features:
|
|
487
515
|
self.setFeatures(features)
|
|
488
516
|
|
|
517
|
+
# Register for automatic feature updates when cache expires
|
|
518
|
+
if self._client_key:
|
|
519
|
+
feature_repo.add_feature_update_callback(self._on_feature_update)
|
|
520
|
+
|
|
521
|
+
self._initialize_plugins()
|
|
522
|
+
|
|
489
523
|
if self._streaming:
|
|
490
524
|
self.load_features()
|
|
491
525
|
self.startAutoRefresh()
|
|
492
526
|
|
|
527
|
+
def _on_feature_update(self, features_data: Dict) -> None:
|
|
528
|
+
"""Callback to handle automatic feature updates from FeatureRepository"""
|
|
529
|
+
if features_data and "features" in features_data:
|
|
530
|
+
self.set_features(features_data["features"])
|
|
531
|
+
if features_data and "savedGroups" in features_data:
|
|
532
|
+
self._saved_groups = features_data["savedGroups"]
|
|
493
533
|
|
|
494
534
|
def load_features(self) -> None:
|
|
495
535
|
|
|
@@ -595,6 +635,13 @@ class GrowthBook(object):
|
|
|
595
635
|
return self._attributes
|
|
596
636
|
|
|
597
637
|
def destroy(self) -> None:
|
|
638
|
+
# Clean up plugins first
|
|
639
|
+
self._cleanup_plugins()
|
|
640
|
+
|
|
641
|
+
# Clean up feature update callback
|
|
642
|
+
if self._client_key:
|
|
643
|
+
feature_repo.remove_feature_update_callback(self._on_feature_update)
|
|
644
|
+
|
|
598
645
|
self._subscriptions.clear()
|
|
599
646
|
self._tracked.clear()
|
|
600
647
|
self._assigned.clear()
|
|
@@ -631,7 +678,21 @@ class GrowthBook(object):
|
|
|
631
678
|
def evalFeature(self, key: str) -> FeatureResult:
|
|
632
679
|
return self.eval_feature(key)
|
|
633
680
|
|
|
681
|
+
def _ensure_fresh_features(self) -> None:
|
|
682
|
+
"""Lazy refresh: Check cache expiry and refresh if needed, but only if client_key is provided"""
|
|
683
|
+
|
|
684
|
+
if self._streaming or not self._client_key:
|
|
685
|
+
return # Skip cache checks - SSE handles freshness for streaming users
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
self.load_features()
|
|
689
|
+
except Exception as e:
|
|
690
|
+
logger.warning(f"Failed to refresh features: {e}")
|
|
691
|
+
|
|
634
692
|
def _get_eval_context(self) -> EvaluationContext:
|
|
693
|
+
# Lazy refresh: ensure features are fresh before evaluation
|
|
694
|
+
self._ensure_fresh_features()
|
|
695
|
+
|
|
635
696
|
# use the latest attributes for every evaluation.
|
|
636
697
|
self._user_ctx.attributes = self._attributes
|
|
637
698
|
self._user_ctx.url = self._url
|
|
@@ -647,7 +708,8 @@ class GrowthBook(object):
|
|
|
647
708
|
def eval_feature(self, key: str) -> FeatureResult:
|
|
648
709
|
return core_eval_feature(key=key,
|
|
649
710
|
evalContext=self._get_eval_context(),
|
|
650
|
-
callback_subscription=self._fireSubscriptions
|
|
711
|
+
callback_subscription=self._fireSubscriptions,
|
|
712
|
+
tracking_cb=self._track
|
|
651
713
|
)
|
|
652
714
|
|
|
653
715
|
# @deprecated, use get_all_results
|
|
@@ -744,3 +806,33 @@ class GrowthBook(object):
|
|
|
744
806
|
self._sticky_bucket_assignment_docs = self.sticky_bucket_service.get_all_assignments(attributes)
|
|
745
807
|
# Update the user context with the new sticky bucket assignment docs
|
|
746
808
|
self._user_ctx.sticky_bucket_assignment_docs = self._sticky_bucket_assignment_docs
|
|
809
|
+
|
|
810
|
+
def _initialize_plugins(self) -> None:
|
|
811
|
+
"""Initialize all plugins with this GrowthBook instance."""
|
|
812
|
+
for plugin in self._plugins:
|
|
813
|
+
try:
|
|
814
|
+
if hasattr(plugin, 'initialize'):
|
|
815
|
+
# Plugin is a class instance with initialize method
|
|
816
|
+
plugin.initialize(self)
|
|
817
|
+
self._initialized_plugins.append(plugin)
|
|
818
|
+
logger.debug(f"Initialized plugin: {plugin.__class__.__name__}")
|
|
819
|
+
elif callable(plugin):
|
|
820
|
+
# Plugin is a callable function
|
|
821
|
+
plugin(self)
|
|
822
|
+
self._initialized_plugins.append(plugin)
|
|
823
|
+
logger.debug(f"Initialized callable plugin: {plugin.__name__}")
|
|
824
|
+
else:
|
|
825
|
+
logger.warning(f"Plugin {plugin} is neither callable nor has initialize method")
|
|
826
|
+
except Exception as e:
|
|
827
|
+
logger.error(f"Failed to initialize plugin {plugin}: {e}")
|
|
828
|
+
|
|
829
|
+
def _cleanup_plugins(self) -> None:
|
|
830
|
+
"""Cleanup all initialized plugins."""
|
|
831
|
+
for plugin in self._initialized_plugins:
|
|
832
|
+
try:
|
|
833
|
+
if hasattr(plugin, 'cleanup'):
|
|
834
|
+
plugin.cleanup()
|
|
835
|
+
logger.debug(f"Cleaned up plugin: {plugin.__class__.__name__}")
|
|
836
|
+
except Exception as e:
|
|
837
|
+
logger.error(f"Error cleaning up plugin {plugin}: {e}")
|
|
838
|
+
self._initialized_plugins.clear()
|
growthbook/growthbook_client.py
CHANGED
|
@@ -10,11 +10,7 @@ import threading
|
|
|
10
10
|
import traceback
|
|
11
11
|
from datetime import datetime
|
|
12
12
|
from growthbook import FeatureRepository
|
|
13
|
-
|
|
14
|
-
from contextlib import asynccontextmanager
|
|
15
|
-
except ImportError:
|
|
16
|
-
# to support python 3.6
|
|
17
|
-
from async_generator import asynccontextmanager
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
18
14
|
|
|
19
15
|
from .core import eval_feature as core_eval_feature, run_experiment
|
|
20
16
|
from .common_types import (
|
|
@@ -34,7 +30,7 @@ logger = logging.getLogger("growthbook.growthbook_client")
|
|
|
34
30
|
|
|
35
31
|
class SingletonMeta(type):
|
|
36
32
|
"""Thread-safe implementation of Singleton pattern"""
|
|
37
|
-
_instances = {}
|
|
33
|
+
_instances: Dict[type, Any] = {}
|
|
38
34
|
_lock = threading.Lock()
|
|
39
35
|
|
|
40
36
|
def __call__(cls, *args, **kwargs):
|
|
@@ -113,11 +109,11 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
|
|
|
113
109
|
self._decryption_key = decryption_key
|
|
114
110
|
self._cache_ttl = cache_ttl
|
|
115
111
|
self._refresh_lock = threading.Lock()
|
|
116
|
-
self._refresh_task = None
|
|
112
|
+
self._refresh_task: Optional[asyncio.Task] = None
|
|
117
113
|
self._stop_event = asyncio.Event()
|
|
118
114
|
self._backoff = BackoffStrategy()
|
|
119
115
|
self._feature_cache = FeatureCache()
|
|
120
|
-
self._callbacks = []
|
|
116
|
+
self._callbacks: List[Callable[[Dict[str, Any]], Awaitable[None]]] = []
|
|
121
117
|
self._last_successful_refresh = None
|
|
122
118
|
self._refresh_in_progress = asyncio.Lock()
|
|
123
119
|
|
|
@@ -161,13 +157,13 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
|
|
|
161
157
|
except Exception:
|
|
162
158
|
traceback.print_exc()
|
|
163
159
|
|
|
164
|
-
def add_callback(self, callback: Callable) -> None:
|
|
160
|
+
def add_callback(self, callback: Callable[[Dict[str, Any]], Awaitable[None]]) -> None:
|
|
165
161
|
"""Add callback to the list"""
|
|
166
162
|
with self._refresh_lock:
|
|
167
163
|
if callback not in self._callbacks:
|
|
168
164
|
self._callbacks.append(callback)
|
|
169
165
|
|
|
170
|
-
def remove_callback(self, callback: Callable) -> None:
|
|
166
|
+
def remove_callback(self, callback: Callable[[Dict[str, Any]], Awaitable[None]]) -> None:
|
|
171
167
|
"""Remove callback from the list"""
|
|
172
168
|
with self._refresh_lock:
|
|
173
169
|
if callback in self._callbacks:
|
|
@@ -185,7 +181,8 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
|
|
|
185
181
|
response = await self.load_features_async(
|
|
186
182
|
self._api_host, self._client_key, self._decryption_key, self._cache_ttl
|
|
187
183
|
)
|
|
188
|
-
|
|
184
|
+
if response is not None:
|
|
185
|
+
await self._handle_feature_update(response)
|
|
189
186
|
elif event_data['type'] == 'features':
|
|
190
187
|
await self._handle_feature_update(event_data['data'])
|
|
191
188
|
except Exception:
|
|
@@ -224,7 +221,8 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
|
|
|
224
221
|
decryption_key=self._decryption_key,
|
|
225
222
|
ttl=self._cache_ttl
|
|
226
223
|
)
|
|
227
|
-
|
|
224
|
+
if response is not None:
|
|
225
|
+
await self._handle_feature_update(response)
|
|
228
226
|
# On success, reset backoff and use normal interval
|
|
229
227
|
self._backoff.reset()
|
|
230
228
|
try:
|
|
@@ -313,21 +311,27 @@ class GrowthBookClient:
|
|
|
313
311
|
self._subscriptions_lock = threading.Lock()
|
|
314
312
|
|
|
315
313
|
# Add sticky bucket cache
|
|
316
|
-
self._sticky_bucket_cache = {
|
|
314
|
+
self._sticky_bucket_cache: Dict[str, Dict[str, Any]] = {
|
|
317
315
|
'attributes': {},
|
|
318
316
|
'assignments': {}
|
|
319
317
|
}
|
|
320
318
|
self._sticky_bucket_cache_lock = False
|
|
321
319
|
|
|
322
320
|
self._features_repository = (
|
|
323
|
-
EnhancedFeatureRepository(
|
|
321
|
+
EnhancedFeatureRepository(
|
|
322
|
+
self.options.api_host or "https://cdn.growthbook.io",
|
|
323
|
+
self.options.client_key or "",
|
|
324
|
+
self.options.decryption_key or "",
|
|
325
|
+
self.options.cache_ttl
|
|
326
|
+
)
|
|
324
327
|
if self.options.client_key
|
|
325
328
|
else None
|
|
326
329
|
)
|
|
327
330
|
|
|
328
|
-
self._global_context = None
|
|
331
|
+
self._global_context: Optional[GlobalContext] = None
|
|
329
332
|
self._context_lock = asyncio.Lock()
|
|
330
333
|
|
|
334
|
+
|
|
331
335
|
def _track(self, experiment: Experiment, result: Result) -> None:
|
|
332
336
|
"""Thread-safe tracking implementation"""
|
|
333
337
|
if not self.options.on_experiment_viewed:
|
|
@@ -369,6 +373,11 @@ class GrowthBookClient:
|
|
|
369
373
|
except Exception:
|
|
370
374
|
logger.exception("Error in subscription callback")
|
|
371
375
|
|
|
376
|
+
|
|
377
|
+
async def set_features(self, features: dict) -> None:
|
|
378
|
+
await self._feature_update_callback({"features": features})
|
|
379
|
+
|
|
380
|
+
|
|
372
381
|
async def _refresh_sticky_buckets(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
|
|
373
382
|
"""Refresh sticky bucket assignments only if attributes have changed"""
|
|
374
383
|
if not self.options.sticky_bucket_service:
|
|
@@ -387,6 +396,9 @@ class GrowthBookClient:
|
|
|
387
396
|
return assignments
|
|
388
397
|
finally:
|
|
389
398
|
self._sticky_bucket_cache_lock = False
|
|
399
|
+
|
|
400
|
+
# Fallback return for edge case where loop condition is never satisfied
|
|
401
|
+
return {}
|
|
390
402
|
|
|
391
403
|
async def initialize(self) -> bool:
|
|
392
404
|
"""Initialize client with features and start refresh"""
|
|
@@ -397,7 +409,10 @@ class GrowthBookClient:
|
|
|
397
409
|
try:
|
|
398
410
|
# Initial feature load
|
|
399
411
|
initial_features = await self._features_repository.load_features_async(
|
|
400
|
-
self.options.api_host
|
|
412
|
+
self.options.api_host or "https://cdn.growthbook.io",
|
|
413
|
+
self.options.client_key or "",
|
|
414
|
+
self.options.decryption_key or "",
|
|
415
|
+
self.options.cache_ttl
|
|
401
416
|
)
|
|
402
417
|
if not initial_features:
|
|
403
418
|
logger.error("Failed to load initial features")
|
|
@@ -410,7 +425,8 @@ class GrowthBookClient:
|
|
|
410
425
|
self._features_repository.add_callback(self._feature_update_callback)
|
|
411
426
|
|
|
412
427
|
# Start feature refresh
|
|
413
|
-
|
|
428
|
+
refresh_strategy = self.options.refresh_strategy or FeatureRefreshStrategy.STALE_WHILE_REVALIDATE
|
|
429
|
+
await self._features_repository.start_feature_refresh(refresh_strategy)
|
|
414
430
|
return True
|
|
415
431
|
|
|
416
432
|
except Exception as e:
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .base import GrowthBookPlugin
|
|
2
|
+
# from .auto_attributes import auto_attributes_plugin, AutoAttributesPlugin
|
|
3
|
+
from .growthbook_tracking import growthbook_tracking_plugin, GrowthBookTrackingPlugin
|
|
4
|
+
from .request_context import request_context_plugin, client_side_attributes, RequestContextPlugin, ClientSideAttributes
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'GrowthBookPlugin',
|
|
8
|
+
# 'auto_attributes_plugin',
|
|
9
|
+
# 'AutoAttributesPlugin',
|
|
10
|
+
'growthbook_tracking_plugin',
|
|
11
|
+
'GrowthBookTrackingPlugin',
|
|
12
|
+
'request_context_plugin',
|
|
13
|
+
'client_side_attributes',
|
|
14
|
+
'RequestContextPlugin',
|
|
15
|
+
'ClientSideAttributes',
|
|
16
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GrowthBookPlugin(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Base class for all GrowthBook plugins.
|
|
11
|
+
|
|
12
|
+
Plugins extend GrowthBook functionality by adding auto-attributes,
|
|
13
|
+
tracking capabilities, or other enhancements.
|
|
14
|
+
|
|
15
|
+
Lifecycle:
|
|
16
|
+
1. Plugin is instantiated with configuration options
|
|
17
|
+
2. initialize(gb_instance) is called when GrowthBook is created
|
|
18
|
+
3. Plugin enhances GrowthBook functionality
|
|
19
|
+
4. cleanup() is called when GrowthBook.destroy() is called
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, **options):
|
|
23
|
+
"""Initialize plugin with configuration options."""
|
|
24
|
+
self.options = options
|
|
25
|
+
self._initialized = False
|
|
26
|
+
self._gb_instance = None
|
|
27
|
+
self.logger = logging.getLogger(f"{self.__class__.__module__}.{self.__class__.__name__}")
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def initialize(self, gb_instance) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Initialize the plugin with a GrowthBook instance.
|
|
33
|
+
|
|
34
|
+
This method is called automatically when the GrowthBook instance
|
|
35
|
+
is created. Use this to set up the plugin functionality.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
gb_instance: The GrowthBook instance to enhance
|
|
39
|
+
"""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def cleanup(self) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Cleanup plugin resources when GrowthBook instance is destroyed.
|
|
45
|
+
|
|
46
|
+
Override this method if your plugin needs to:
|
|
47
|
+
- Close network connections
|
|
48
|
+
- Cancel timers/threads
|
|
49
|
+
- Flush pending data
|
|
50
|
+
- Release resources
|
|
51
|
+
|
|
52
|
+
Default implementation does nothing.
|
|
53
|
+
"""
|
|
54
|
+
self.logger.debug(f"Cleaning up plugin {self.__class__.__name__}")
|
|
55
|
+
self._gb_instance = None
|
|
56
|
+
|
|
57
|
+
def is_initialized(self) -> bool:
|
|
58
|
+
"""Check if plugin has been initialized."""
|
|
59
|
+
return self._initialized
|
|
60
|
+
|
|
61
|
+
def _set_initialized(self, gb_instance) -> None:
|
|
62
|
+
"""Mark plugin as initialized and store GrowthBook reference."""
|
|
63
|
+
self._initialized = True
|
|
64
|
+
self._gb_instance = gb_instance
|
|
65
|
+
self.logger.debug(f"Plugin {self.__class__.__name__} initialized successfully")
|
|
66
|
+
|
|
67
|
+
def _get_option(self, key: str, default: Any = None) -> Any:
|
|
68
|
+
"""Get a configuration option with optional default."""
|
|
69
|
+
return self.options.get(key, default)
|
|
70
|
+
|
|
71
|
+
def _merge_attributes(self, new_attributes: Dict[str, Any]) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Helper method to merge new attributes with existing ones.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
new_attributes: Dictionary of attributes to add/update
|
|
77
|
+
"""
|
|
78
|
+
if not self._gb_instance:
|
|
79
|
+
self.logger.warning("Cannot merge attributes - plugin not initialized")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
current_attributes = self._gb_instance.get_attributes()
|
|
83
|
+
merged_attributes = {**new_attributes, **current_attributes} # Existing attrs take precedence
|
|
84
|
+
self._gb_instance.set_attributes(merged_attributes)
|
|
85
|
+
|
|
86
|
+
self.logger.debug(f"Merged {len(new_attributes)} attributes: {list(new_attributes.keys())}")
|
|
87
|
+
|
|
88
|
+
def _safe_execute(self, func, *args, **kwargs):
|
|
89
|
+
"""
|
|
90
|
+
Safely execute a function, logging any exceptions.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
func: Function to execute
|
|
94
|
+
*args, **kwargs: Arguments to pass to function
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Function result or None if exception occurred
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
return func(*args, **kwargs)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
self.logger.error(f"Error in {func.__name__}: {e}")
|
|
103
|
+
return None
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Dict, Any, Optional, List, Callable
|
|
6
|
+
from .base import GrowthBookPlugin
|
|
7
|
+
|
|
8
|
+
requests: Any = None
|
|
9
|
+
try:
|
|
10
|
+
import requests
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("growthbook.plugins.growthbook_tracking")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GrowthBookTrackingPlugin(GrowthBookPlugin):
|
|
18
|
+
"""
|
|
19
|
+
GrowthBook tracking plugin for Built-in Warehouse.
|
|
20
|
+
|
|
21
|
+
This plugin automatically tracks "Experiment Viewed" and "Feature Evaluated"
|
|
22
|
+
events to GrowthBook's built-in data warehouse.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
ingestor_host: str,
|
|
28
|
+
track_experiment_viewed: bool = True,
|
|
29
|
+
track_feature_evaluated: bool = True,
|
|
30
|
+
batch_size: int = 10,
|
|
31
|
+
batch_timeout: float = 10.0,
|
|
32
|
+
additional_callback: Optional[Callable] = None,
|
|
33
|
+
**options
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Initialize GrowthBook tracking plugin.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
ingestor_host: The GrowthBook ingestor endpoint
|
|
40
|
+
track_experiment_viewed: Whether to track experiment viewed events
|
|
41
|
+
track_feature_evaluated: Whether to track feature evaluated events
|
|
42
|
+
batch_size: Number of events to batch before sending
|
|
43
|
+
batch_timeout: Maximum time (seconds) to wait before sending a batch
|
|
44
|
+
additional_callback: Optional additional tracking callback
|
|
45
|
+
"""
|
|
46
|
+
super().__init__(**options)
|
|
47
|
+
|
|
48
|
+
if not requests:
|
|
49
|
+
raise ImportError("requests library is required for GrowthBookTrackingPlugin. Install with: pip install requests")
|
|
50
|
+
|
|
51
|
+
self.ingestor_host = ingestor_host.rstrip('/')
|
|
52
|
+
self.track_experiment_viewed = track_experiment_viewed
|
|
53
|
+
self.track_feature_evaluated = track_feature_evaluated
|
|
54
|
+
self.batch_size = batch_size
|
|
55
|
+
self.batch_timeout = batch_timeout
|
|
56
|
+
self.additional_callback = additional_callback
|
|
57
|
+
|
|
58
|
+
# batching
|
|
59
|
+
self._event_batch: List[Dict[str, Any]] = []
|
|
60
|
+
self._batch_lock = threading.Lock()
|
|
61
|
+
self._flush_timer: Optional[threading.Timer] = None
|
|
62
|
+
self._client_key: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
def initialize(self, gb_instance) -> None:
|
|
65
|
+
"""Initialize plugin with GrowthBook instance."""
|
|
66
|
+
try:
|
|
67
|
+
self._client_key = getattr(gb_instance, '_client_key', '')
|
|
68
|
+
|
|
69
|
+
# Hook into experiment tracking
|
|
70
|
+
if self.track_experiment_viewed:
|
|
71
|
+
self._setup_experiment_tracking(gb_instance)
|
|
72
|
+
|
|
73
|
+
# Hook into feature evaluation
|
|
74
|
+
if self.track_feature_evaluated:
|
|
75
|
+
self._setup_feature_tracking(gb_instance)
|
|
76
|
+
|
|
77
|
+
self._set_initialized(gb_instance)
|
|
78
|
+
self.logger.info(f"Tracking enabled for {self.ingestor_host}")
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
self.logger.error(f"Failed to initialize tracking plugin: {e}")
|
|
82
|
+
|
|
83
|
+
def cleanup(self) -> None:
|
|
84
|
+
"""Cleanup plugin resources."""
|
|
85
|
+
self._flush_events()
|
|
86
|
+
if self._flush_timer:
|
|
87
|
+
self._flush_timer.cancel()
|
|
88
|
+
super().cleanup()
|
|
89
|
+
|
|
90
|
+
def _setup_experiment_tracking(self, gb_instance) -> None:
|
|
91
|
+
"""Setup experiment tracking."""
|
|
92
|
+
original_callback = getattr(gb_instance, '_trackingCallback', None)
|
|
93
|
+
|
|
94
|
+
def tracking_wrapper(experiment, result):
|
|
95
|
+
# Track to ingestor
|
|
96
|
+
self._track_experiment_viewed(experiment, result)
|
|
97
|
+
|
|
98
|
+
# Call additional callback
|
|
99
|
+
if self.additional_callback:
|
|
100
|
+
self._safe_execute(self.additional_callback, experiment, result)
|
|
101
|
+
|
|
102
|
+
# Call original callback
|
|
103
|
+
if original_callback:
|
|
104
|
+
self._safe_execute(original_callback, experiment, result)
|
|
105
|
+
|
|
106
|
+
gb_instance._trackingCallback = tracking_wrapper
|
|
107
|
+
|
|
108
|
+
def _setup_feature_tracking(self, gb_instance):
|
|
109
|
+
"""Setup feature evaluation tracking."""
|
|
110
|
+
original_eval_feature = gb_instance.eval_feature
|
|
111
|
+
|
|
112
|
+
def eval_feature_wrapper(key: str):
|
|
113
|
+
result = original_eval_feature(key)
|
|
114
|
+
self._track_feature_evaluated(key, result, gb_instance)
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
gb_instance.eval_feature = eval_feature_wrapper
|
|
118
|
+
|
|
119
|
+
def _track_experiment_viewed(self, experiment, result) -> None:
|
|
120
|
+
"""Track experiment viewed event."""
|
|
121
|
+
try:
|
|
122
|
+
# Build event data with all metadata
|
|
123
|
+
event_data = {
|
|
124
|
+
'event_type': 'experiment_viewed',
|
|
125
|
+
'timestamp': int(time.time() * 1000),
|
|
126
|
+
'client_key': self._client_key,
|
|
127
|
+
'sdk_language': 'python',
|
|
128
|
+
'sdk_version': self._get_sdk_version(),
|
|
129
|
+
# Core experiment data
|
|
130
|
+
'experiment_id': experiment.key,
|
|
131
|
+
'variation_id': result.variationId,
|
|
132
|
+
'variation_key': getattr(result, 'key', str(result.variationId)),
|
|
133
|
+
'variation_value': result.value,
|
|
134
|
+
'in_experiment': result.inExperiment,
|
|
135
|
+
'hash_used': result.hashUsed,
|
|
136
|
+
'hash_attribute': result.hashAttribute,
|
|
137
|
+
'hash_value': result.hashValue,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# Add optional metadata if available
|
|
141
|
+
if hasattr(experiment, 'name') and experiment.name:
|
|
142
|
+
event_data['experiment_name'] = experiment.name
|
|
143
|
+
if hasattr(result, 'featureId') and result.featureId:
|
|
144
|
+
event_data['feature_id'] = result.featureId
|
|
145
|
+
|
|
146
|
+
self._add_event_to_batch(event_data)
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
self.logger.error(f"Error tracking experiment: {e}")
|
|
150
|
+
|
|
151
|
+
def _track_feature_evaluated(self, feature_key: str, result, gb_instance) -> None:
|
|
152
|
+
"""Track feature evaluated event."""
|
|
153
|
+
try:
|
|
154
|
+
# Build event data with all metadata
|
|
155
|
+
event_data = {
|
|
156
|
+
'event_type': 'feature_evaluated',
|
|
157
|
+
'timestamp': int(time.time() * 1000),
|
|
158
|
+
'client_key': self._client_key,
|
|
159
|
+
'sdk_language': 'python',
|
|
160
|
+
'sdk_version': self._get_sdk_version(),
|
|
161
|
+
# Core feature data
|
|
162
|
+
'feature_key': feature_key,
|
|
163
|
+
'feature_value': result.value,
|
|
164
|
+
'source': result.source,
|
|
165
|
+
'on': getattr(result, 'on', bool(result.value)),
|
|
166
|
+
'off': getattr(result, 'off', not bool(result.value)),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Add optional metadata if available
|
|
170
|
+
if hasattr(result, 'ruleId') and result.ruleId:
|
|
171
|
+
event_data['rule_id'] = result.ruleId
|
|
172
|
+
|
|
173
|
+
# Add experiment info if feature came from experiment
|
|
174
|
+
if hasattr(result, 'experiment') and result.experiment:
|
|
175
|
+
event_data['experiment_id'] = result.experiment.key
|
|
176
|
+
if hasattr(result, 'experimentResult') and result.experimentResult:
|
|
177
|
+
event_data['variation_id'] = result.experimentResult.variationId
|
|
178
|
+
event_data['in_experiment'] = result.experimentResult.inExperiment
|
|
179
|
+
|
|
180
|
+
self._add_event_to_batch(event_data)
|
|
181
|
+
|
|
182
|
+
except Exception as e:
|
|
183
|
+
self.logger.error(f"Error tracking feature: {e}")
|
|
184
|
+
|
|
185
|
+
def _add_event_to_batch(self, event_data: Dict[str, Any]) -> None:
|
|
186
|
+
with self._batch_lock:
|
|
187
|
+
self._event_batch.append(event_data)
|
|
188
|
+
|
|
189
|
+
# Flush if batch is full
|
|
190
|
+
if len(self._event_batch) >= self.batch_size:
|
|
191
|
+
self._flush_batch_locked()
|
|
192
|
+
elif len(self._event_batch) == 1:
|
|
193
|
+
# Start timer for first event
|
|
194
|
+
self._start_flush_timer()
|
|
195
|
+
|
|
196
|
+
def _start_flush_timer(self) -> None:
|
|
197
|
+
"""Start flush timer."""
|
|
198
|
+
if self._flush_timer:
|
|
199
|
+
self._flush_timer.cancel()
|
|
200
|
+
|
|
201
|
+
self._flush_timer = threading.Timer(self.batch_timeout, self._flush_events)
|
|
202
|
+
self._flush_timer.start()
|
|
203
|
+
|
|
204
|
+
def _flush_events(self) -> None:
|
|
205
|
+
"""Flush events with lock."""
|
|
206
|
+
with self._batch_lock:
|
|
207
|
+
self._flush_batch_locked()
|
|
208
|
+
|
|
209
|
+
def _flush_batch_locked(self) -> None:
|
|
210
|
+
"""Flush current batch (called while holding lock)."""
|
|
211
|
+
if not self._event_batch:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
events_to_send = self._event_batch.copy()
|
|
215
|
+
self._event_batch.clear()
|
|
216
|
+
|
|
217
|
+
if self._flush_timer:
|
|
218
|
+
self._flush_timer.cancel()
|
|
219
|
+
self._flush_timer = None
|
|
220
|
+
|
|
221
|
+
# Send in background thread
|
|
222
|
+
threading.Thread(target=self._send_events, args=(events_to_send,), daemon=True).start()
|
|
223
|
+
|
|
224
|
+
def _send_events(self, events: List[Dict[str, Any]]) -> None:
|
|
225
|
+
"""Send events using requests library."""
|
|
226
|
+
if not events:
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
payload = {
|
|
231
|
+
'events': events,
|
|
232
|
+
'client_key': self._client_key
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
url = f"{self.ingestor_host}/events"
|
|
236
|
+
response = requests.post(
|
|
237
|
+
url,
|
|
238
|
+
json=payload,
|
|
239
|
+
headers={'User-Agent': f'growthbook-python-sdk/{self._get_sdk_version()}'},
|
|
240
|
+
timeout=30
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if response.status_code == 200:
|
|
244
|
+
self.logger.debug(f"Successfully sent {len(events)} events")
|
|
245
|
+
else:
|
|
246
|
+
self.logger.warning(f"Ingestor returned status {response.status_code}")
|
|
247
|
+
|
|
248
|
+
except Exception as e:
|
|
249
|
+
self.logger.error(f"Failed to send events: {e}")
|
|
250
|
+
|
|
251
|
+
def _get_sdk_version(self) -> str:
|
|
252
|
+
"""Get SDK version."""
|
|
253
|
+
try:
|
|
254
|
+
import growthbook
|
|
255
|
+
return getattr(growthbook, '__version__', 'unknown')
|
|
256
|
+
except:
|
|
257
|
+
return 'unknown'
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def growthbook_tracking_plugin(**options) -> GrowthBookTrackingPlugin:
|
|
261
|
+
"""Create a GrowthBook tracking plugin."""
|
|
262
|
+
return GrowthBookTrackingPlugin(**options)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Request Context Plugin for GrowthBook Python SDK
|
|
3
|
+
|
|
4
|
+
This plugin extracts attributes from HTTP request context using a framework-agnostic approach.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import uuid
|
|
9
|
+
import time
|
|
10
|
+
from typing import Dict, Any, Optional, Callable, Union
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
from .base import GrowthBookPlugin
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("growthbook.plugins.request_context")
|
|
15
|
+
|
|
16
|
+
# Global context variable for storing current request
|
|
17
|
+
_current_request_context: Dict[str, Any] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ClientSideAttributes:
|
|
21
|
+
"""
|
|
22
|
+
Client-side attributes that can't be detected server-side.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, **attributes: Any):
|
|
26
|
+
"""
|
|
27
|
+
Initialize with any client-side attributes.
|
|
28
|
+
|
|
29
|
+
Common attributes:
|
|
30
|
+
pageTitle: Current page title
|
|
31
|
+
deviceType: "mobile" | "desktop" | "tablet"
|
|
32
|
+
browser: "chrome" | "firefox" | "safari" | "edge"
|
|
33
|
+
timezone: User's timezone (e.g., "America/New_York")
|
|
34
|
+
language: User's language (e.g., "en-US")
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
**attributes: Any client-side attributes as key-value pairs
|
|
38
|
+
"""
|
|
39
|
+
for key, value in attributes.items():
|
|
40
|
+
if value is not None:
|
|
41
|
+
setattr(self, key, value)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
44
|
+
"""Convert to dictionary."""
|
|
45
|
+
return {k: v for k, v in self.__dict__.items() if v is not None}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RequestContextPlugin(GrowthBookPlugin):
|
|
49
|
+
"""
|
|
50
|
+
Framework-agnostic request context plugin.
|
|
51
|
+
|
|
52
|
+
This plugin uses:
|
|
53
|
+
1. Manual request object passing via set_request_context()
|
|
54
|
+
2. Context variables set by middleware
|
|
55
|
+
3. Direct attribute extraction from provided data
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
request_extractor: Optional[Callable] = None,
|
|
61
|
+
client_side_attributes: Optional[ClientSideAttributes] = None,
|
|
62
|
+
extract_utm: bool = True,
|
|
63
|
+
extract_user_agent: bool = True,
|
|
64
|
+
**options
|
|
65
|
+
):
|
|
66
|
+
"""
|
|
67
|
+
Initialize request context plugin.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
request_extractor: Optional function to extract request object from context
|
|
71
|
+
client_side_attributes: Manual client-side attributes
|
|
72
|
+
extract_utm: Whether to extract UTM parameters
|
|
73
|
+
extract_user_agent: Whether to parse User-Agent header
|
|
74
|
+
"""
|
|
75
|
+
super().__init__(**options)
|
|
76
|
+
self.request_extractor = request_extractor
|
|
77
|
+
self.client_side_attributes = client_side_attributes
|
|
78
|
+
self.extract_utm = extract_utm
|
|
79
|
+
self.extract_user_agent = extract_user_agent
|
|
80
|
+
|
|
81
|
+
def initialize(self, gb_instance) -> None:
|
|
82
|
+
"""Initialize plugin - extract attributes from request context."""
|
|
83
|
+
try:
|
|
84
|
+
self._set_initialized(gb_instance)
|
|
85
|
+
|
|
86
|
+
# Get request data from various sources
|
|
87
|
+
request_attributes = self._extract_all_attributes()
|
|
88
|
+
|
|
89
|
+
if request_attributes:
|
|
90
|
+
# Merge with existing attributes (existing take precedence)
|
|
91
|
+
current_attributes = gb_instance.get_attributes()
|
|
92
|
+
merged_attributes = {**request_attributes, **current_attributes}
|
|
93
|
+
gb_instance.set_attributes(merged_attributes)
|
|
94
|
+
|
|
95
|
+
self.logger.info(f"Extracted {len(request_attributes)} request attributes")
|
|
96
|
+
else:
|
|
97
|
+
self.logger.debug("No request context available")
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
self.logger.error(f"Failed to extract request attributes: {e}")
|
|
101
|
+
|
|
102
|
+
def _extract_all_attributes(self) -> Dict[str, Any]:
|
|
103
|
+
"""Extract all available attributes from request context."""
|
|
104
|
+
attributes = {}
|
|
105
|
+
|
|
106
|
+
# Get request object/data
|
|
107
|
+
request_data = self._get_request_data()
|
|
108
|
+
|
|
109
|
+
if request_data:
|
|
110
|
+
# Extract core request info
|
|
111
|
+
attributes.update(self._extract_basic_info(request_data))
|
|
112
|
+
|
|
113
|
+
# Extract UTM parameters
|
|
114
|
+
if self.extract_utm:
|
|
115
|
+
attributes.update(self._extract_utm_params(request_data))
|
|
116
|
+
|
|
117
|
+
# Extract User-Agent info
|
|
118
|
+
if self.extract_user_agent:
|
|
119
|
+
attributes.update(self._extract_user_agent(request_data))
|
|
120
|
+
|
|
121
|
+
# Add client-side attributes (these override auto-detected)
|
|
122
|
+
if self.client_side_attributes:
|
|
123
|
+
attributes.update(self.client_side_attributes.to_dict())
|
|
124
|
+
|
|
125
|
+
# Add server context
|
|
126
|
+
attributes.update({
|
|
127
|
+
'server_timestamp': int(time.time()),
|
|
128
|
+
'request_id': str(uuid.uuid4())[:8],
|
|
129
|
+
'sdk_context': 'server'
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return attributes
|
|
133
|
+
|
|
134
|
+
def _get_request_data(self) -> Optional[Dict[str, Any]]:
|
|
135
|
+
"""Get request data from various sources."""
|
|
136
|
+
# 1. Try custom extractor
|
|
137
|
+
if self.request_extractor:
|
|
138
|
+
try:
|
|
139
|
+
request_obj = self.request_extractor()
|
|
140
|
+
if request_obj:
|
|
141
|
+
return self._normalize_request_object(request_obj)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
self.logger.debug(f"Custom extractor failed: {e}")
|
|
144
|
+
|
|
145
|
+
# 2. Try global context
|
|
146
|
+
if _current_request_context:
|
|
147
|
+
return _current_request_context.copy()
|
|
148
|
+
|
|
149
|
+
# 3. Try thread-local storage
|
|
150
|
+
import threading
|
|
151
|
+
thread_local = getattr(threading.current_thread(), 'gb_request_context', None)
|
|
152
|
+
if thread_local:
|
|
153
|
+
return thread_local
|
|
154
|
+
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def _normalize_request_object(self, request_obj) -> Dict[str, Any]:
|
|
158
|
+
"""Convert various request objects to normalized dict."""
|
|
159
|
+
normalized = {}
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
# Extract URL
|
|
163
|
+
url = None
|
|
164
|
+
if hasattr(request_obj, 'build_absolute_uri'): # Django
|
|
165
|
+
url = request_obj.build_absolute_uri()
|
|
166
|
+
elif hasattr(request_obj, 'url'): # Flask/FastAPI
|
|
167
|
+
url = str(request_obj.url)
|
|
168
|
+
|
|
169
|
+
if url:
|
|
170
|
+
normalized['url'] = url
|
|
171
|
+
parsed = urlparse(url)
|
|
172
|
+
normalized['path'] = parsed.path
|
|
173
|
+
normalized['host'] = parsed.netloc
|
|
174
|
+
normalized['query_string'] = parsed.query
|
|
175
|
+
|
|
176
|
+
# Extract query parameters
|
|
177
|
+
query_params = {}
|
|
178
|
+
if hasattr(request_obj, 'GET'): # Django
|
|
179
|
+
query_params = dict(request_obj.GET)
|
|
180
|
+
elif hasattr(request_obj, 'args'): # Flask
|
|
181
|
+
query_params = dict(request_obj.args)
|
|
182
|
+
elif hasattr(request_obj, 'query_params'): # FastAPI
|
|
183
|
+
query_params = dict(request_obj.query_params)
|
|
184
|
+
|
|
185
|
+
normalized['query_params'] = query_params
|
|
186
|
+
|
|
187
|
+
# Extract User-Agent
|
|
188
|
+
user_agent = None
|
|
189
|
+
if hasattr(request_obj, 'META'): # Django
|
|
190
|
+
user_agent = request_obj.META.get('HTTP_USER_AGENT')
|
|
191
|
+
elif hasattr(request_obj, 'headers'): # Flask/FastAPI
|
|
192
|
+
user_agent = request_obj.headers.get('user-agent') or request_obj.headers.get('User-Agent')
|
|
193
|
+
|
|
194
|
+
if user_agent:
|
|
195
|
+
normalized['user_agent'] = user_agent
|
|
196
|
+
|
|
197
|
+
# Extract user info (if available)
|
|
198
|
+
if hasattr(request_obj, 'user') and hasattr(request_obj.user, 'id'):
|
|
199
|
+
if getattr(request_obj.user, 'is_authenticated', True):
|
|
200
|
+
normalized['user_id'] = str(request_obj.user.id)
|
|
201
|
+
|
|
202
|
+
except Exception as e:
|
|
203
|
+
self.logger.debug(f"Error normalizing request object: {e}")
|
|
204
|
+
|
|
205
|
+
return normalized
|
|
206
|
+
|
|
207
|
+
def _extract_basic_info(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
208
|
+
"""Extract basic request information."""
|
|
209
|
+
info = {}
|
|
210
|
+
|
|
211
|
+
if 'url' in request_data:
|
|
212
|
+
info['url'] = request_data['url']
|
|
213
|
+
if 'path' in request_data:
|
|
214
|
+
info['path'] = request_data['path']
|
|
215
|
+
if 'host' in request_data:
|
|
216
|
+
info['host'] = request_data['host']
|
|
217
|
+
if 'user_id' in request_data:
|
|
218
|
+
info['id'] = request_data['user_id']
|
|
219
|
+
|
|
220
|
+
return info
|
|
221
|
+
|
|
222
|
+
def _extract_utm_params(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
223
|
+
"""Extract UTM parameters from query string."""
|
|
224
|
+
utm_params = {}
|
|
225
|
+
query_params = request_data.get('query_params', {})
|
|
226
|
+
|
|
227
|
+
utm_mappings = {
|
|
228
|
+
'utm_source': 'utmSource',
|
|
229
|
+
'utm_medium': 'utmMedium',
|
|
230
|
+
'utm_campaign': 'utmCampaign',
|
|
231
|
+
'utm_term': 'utmTerm',
|
|
232
|
+
'utm_content': 'utmContent'
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for param, attr_name in utm_mappings.items():
|
|
236
|
+
value = query_params.get(param)
|
|
237
|
+
if value:
|
|
238
|
+
utm_params[attr_name] = value
|
|
239
|
+
|
|
240
|
+
return utm_params
|
|
241
|
+
|
|
242
|
+
def _extract_user_agent(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
243
|
+
"""Extract browser and device info from User-Agent."""
|
|
244
|
+
user_agent = request_data.get('user_agent')
|
|
245
|
+
if not user_agent:
|
|
246
|
+
return {}
|
|
247
|
+
|
|
248
|
+
ua_lower = user_agent.lower()
|
|
249
|
+
info = {'userAgent': user_agent}
|
|
250
|
+
|
|
251
|
+
# Simple browser detection
|
|
252
|
+
if 'edge' in ua_lower or 'edg' in ua_lower:
|
|
253
|
+
info['browser'] = 'edge'
|
|
254
|
+
elif 'chrome' in ua_lower:
|
|
255
|
+
info['browser'] = 'chrome'
|
|
256
|
+
elif 'firefox' in ua_lower:
|
|
257
|
+
info['browser'] = 'firefox'
|
|
258
|
+
elif 'safari' in ua_lower:
|
|
259
|
+
info['browser'] = 'safari'
|
|
260
|
+
else:
|
|
261
|
+
info['browser'] = 'unknown'
|
|
262
|
+
|
|
263
|
+
# Simple device detection
|
|
264
|
+
mobile_indicators = ['mobile', 'android', 'iphone', 'ipad']
|
|
265
|
+
if any(indicator in ua_lower for indicator in mobile_indicators):
|
|
266
|
+
info['deviceType'] = 'mobile'
|
|
267
|
+
else:
|
|
268
|
+
info['deviceType'] = 'desktop'
|
|
269
|
+
|
|
270
|
+
return info
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# Framework-agnostic helper functions
|
|
274
|
+
def set_request_context(request_data: Union[Dict[str, Any], Any]) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Set request context globally for the current thread.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
request_data: Either a dict of request data or a request object to normalize
|
|
280
|
+
"""
|
|
281
|
+
global _current_request_context
|
|
282
|
+
|
|
283
|
+
if isinstance(request_data, dict):
|
|
284
|
+
_current_request_context = request_data
|
|
285
|
+
else:
|
|
286
|
+
# Try to normalize request object
|
|
287
|
+
plugin = RequestContextPlugin()
|
|
288
|
+
_current_request_context = plugin._normalize_request_object(request_data)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def clear_request_context() -> None:
|
|
292
|
+
"""Clear the global request context."""
|
|
293
|
+
global _current_request_context
|
|
294
|
+
_current_request_context = {}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# Convenience functions
|
|
298
|
+
def request_context_plugin(**options) -> RequestContextPlugin:
|
|
299
|
+
"""
|
|
300
|
+
Create a request context plugin.
|
|
301
|
+
|
|
302
|
+
Usage examples:
|
|
303
|
+
|
|
304
|
+
# 1. With middleware setting global context:
|
|
305
|
+
set_request_context(request)
|
|
306
|
+
gb = GrowthBook(plugins=[request_context_plugin()])
|
|
307
|
+
|
|
308
|
+
# 2. With custom extractor:
|
|
309
|
+
def get_current_request():
|
|
310
|
+
return my_framework.get_current_request()
|
|
311
|
+
|
|
312
|
+
gb = GrowthBook(plugins=[
|
|
313
|
+
request_context_plugin(request_extractor=get_current_request)
|
|
314
|
+
])
|
|
315
|
+
|
|
316
|
+
# 3. With client-side attributes:
|
|
317
|
+
gb = GrowthBook(plugins=[
|
|
318
|
+
request_context_plugin(
|
|
319
|
+
client_side_attributes=client_side_attributes(
|
|
320
|
+
pageTitle="Dashboard",
|
|
321
|
+
deviceType="mobile"
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
])
|
|
325
|
+
"""
|
|
326
|
+
return RequestContextPlugin(**options)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def client_side_attributes(**kwargs) -> ClientSideAttributes:
|
|
330
|
+
"""
|
|
331
|
+
Create client-side attributes.
|
|
332
|
+
|
|
333
|
+
Usage:
|
|
334
|
+
attrs = client_side_attributes(
|
|
335
|
+
pageTitle="My Page",
|
|
336
|
+
deviceType="mobile",
|
|
337
|
+
browser="chrome",
|
|
338
|
+
customField="value"
|
|
339
|
+
)
|
|
340
|
+
"""
|
|
341
|
+
return ClientSideAttributes(**kwargs)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: growthbook
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: Powerful Feature flagging and A/B testing for Python apps
|
|
5
5
|
Home-page: https://github.com/growthbook/growthbook-python
|
|
6
6
|
Author: GrowthBook
|
|
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
-
Requires-Python: >=3.
|
|
21
|
+
Requires-Python: >=3.7
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: cryptography
|
|
@@ -30,22 +30,27 @@ Requires-Dist: aiohttp>=3.6.0
|
|
|
30
30
|
Requires-Dist: importlib-metadata; python_version < "3.8"
|
|
31
31
|
Dynamic: author
|
|
32
32
|
Dynamic: home-page
|
|
33
|
+
Dynamic: license-file
|
|
33
34
|
Dynamic: requires-python
|
|
34
35
|
|
|
35
36
|
# GrowthBook Python SDK
|
|
36
37
|
|
|
37
|
-
Powerful Feature flagging and A/B testing for Python apps.
|
|
38
|
-
|
|
39
38
|

|
|
39
|
+
[](https://pypi.org/project/growthbook/)
|
|
40
|
+
[](https://pypi.org/project/growthbook/)
|
|
41
|
+
|
|
42
|
+
## Overview
|
|
43
|
+
|
|
44
|
+
Powerful Feature flagging and A/B testing for Python apps.
|
|
40
45
|
|
|
41
46
|
- **Lightweight and fast**
|
|
42
47
|
- **Local evaluation**, no network requests required
|
|
43
|
-
- Python 3.6+
|
|
44
|
-
- 100% test coverage
|
|
45
48
|
- Flexible **targeting**
|
|
46
49
|
- **Use your existing event tracking** (GA, Segment, Mixpanel, custom)
|
|
47
50
|
- **Remote configuration** to change feature flags without deploying new code
|
|
48
51
|
- **Async support** with real-time feature updates
|
|
52
|
+
- Python 3.6+
|
|
53
|
+
- 100% test coverage
|
|
49
54
|
|
|
50
55
|
## Installation
|
|
51
56
|
|
|
@@ -410,6 +415,33 @@ gb = GrowthBook(
|
|
|
410
415
|
)
|
|
411
416
|
```
|
|
412
417
|
|
|
418
|
+
#### Built-in Tracking Plugin
|
|
419
|
+
|
|
420
|
+
For easier setup, you can use the built-in tracking plugin that automatically sends experiment and feature events to GrowthBook's data warehouse:
|
|
421
|
+
|
|
422
|
+
```python
|
|
423
|
+
from growthbook import GrowthBook
|
|
424
|
+
from growthbook.plugins import growthbook_tracking_plugin, request_context_plugin
|
|
425
|
+
|
|
426
|
+
gb = GrowthBook(
|
|
427
|
+
attributes={"id": "user-123"},
|
|
428
|
+
plugins=[
|
|
429
|
+
request_context_plugin(), # Extracts request data
|
|
430
|
+
growthbook_tracking_plugin(
|
|
431
|
+
ingestor_host="https://gb-ingest.growthbook.io",
|
|
432
|
+
# Optional: Add custom tracking callback
|
|
433
|
+
additional_callback=my_custom_tracker
|
|
434
|
+
)
|
|
435
|
+
]
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Events are now automatically tracked for experiments and features
|
|
439
|
+
result = gb.run(experiment) # -> Tracked automatically
|
|
440
|
+
is_enabled = gb.is_on("my-feature") # -> Tracked automatically
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
The tracking plugin provides batching, error handling, and works alongside your existing tracking callbacks. See the [plugin documentation](https://docs.growthbook.io/lib/python#tracking-plugins) for more details.
|
|
444
|
+
|
|
413
445
|
## Using Features
|
|
414
446
|
|
|
415
447
|
There are 3 main methods for interacting with features.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
growthbook/__init__.py,sha256=2pw6uyYKI4IQ59LZbCUsNroy4LLFZ_5XyPmscPUyAwI,444
|
|
2
|
+
growthbook/common_types.py,sha256=1jDWwS_9L0z_AZb5AJq1arEqi6QU66N3S4vYzkcDFDY,14728
|
|
3
|
+
growthbook/core.py,sha256=LCz5f5rchUL056kgJxgH566NoZWr2nr4hhL7MRlyNiM,35184
|
|
4
|
+
growthbook/growthbook.py,sha256=IJmFKTx87zAwQ6nO_Gepm5-S_Mtwf-lnt2RFBviB6Lk,30672
|
|
5
|
+
growthbook/growthbook_client.py,sha256=zLgoBSBCSfCb8OE0b-XQiDAEB4djTiMSrbNpqBui-M0,21454
|
|
6
|
+
growthbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
growthbook/plugins/__init__.py,sha256=y2eAV1sA041XWcftBVTDH0t-ggy9r2C5oKRYRF6XR6s,602
|
|
8
|
+
growthbook/plugins/base.py,sha256=PWBXUBj62hi25Y5Eif9WmEWagWdkwGXHi2dMtn44bo8,3637
|
|
9
|
+
growthbook/plugins/growthbook_tracking.py,sha256=aL_dgsPt51IZNxRVZVA5U3QEiGW-e-l5tC7Owl6CZO0,10032
|
|
10
|
+
growthbook/plugins/request_context.py,sha256=7_9LOfS5nXbxXE7KJ-zNT4Ug4-_698A4c3t0hjr6l4c,11895
|
|
11
|
+
growthbook-1.3.1.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
|
|
12
|
+
growthbook-1.3.1.dist-info/METADATA,sha256=FwTzARVgHtfSH2ZnCWVq0G9k5ISiPyQDIeYjabR3vCQ,22073
|
|
13
|
+
growthbook-1.3.1.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
|
|
14
|
+
growthbook-1.3.1.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
|
|
15
|
+
growthbook-1.3.1.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
growthbook/__init__.py,sha256=j7q2Y9SF-g2sZKxpqKeyFeRDIxWv_JlBeWmb3NUaOs0,25
|
|
2
|
-
growthbook/common_types.py,sha256=PmZ6fmje0Tvr2rTxUU1pYkyGr-M4CKRnZhJbf9fKSr0,14752
|
|
3
|
-
growthbook/core.py,sha256=kcr5wZ6yWdiHjss408qteqio3NHFMh1sk2JNR4S0OrE,34847
|
|
4
|
-
growthbook/growthbook.py,sha256=mFdURTSTMjcQrl1FkrXGr6SFdXdErQNyIttpcuHlL9E,26360
|
|
5
|
-
growthbook/growthbook_client.py,sha256=KNBL_TQUySl1ZCbYcB2xL_0p2ZTGhUKtjFXW2odSOhU,20647
|
|
6
|
-
growthbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
growthbook-1.2.1.dist-info/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
|
|
8
|
-
growthbook-1.2.1.dist-info/METADATA,sha256=QIaobsseMytueEdiMZXDvDCBnBMGeIbVo_i6D1VYS6A,20805
|
|
9
|
-
growthbook-1.2.1.dist-info/WHEEL,sha256=rF4EZyR2XVS6irmOHQIJx2SUqXLZKRMUrjsg8UwN-XQ,109
|
|
10
|
-
growthbook-1.2.1.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
|
|
11
|
-
growthbook-1.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|