growthbook 1.3.0__tar.gz → 1.4.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.
- {growthbook-1.3.0/growthbook.egg-info → growthbook-1.4.0}/PKG-INFO +1 -1
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/__init__.py +1 -1
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/common_types.py +13 -11
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/core.py +6 -5
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/growthbook.py +5 -4
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/growthbook_client.py +47 -7
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/plugins/growthbook_tracking.py +37 -14
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/plugins/request_context.py +22 -6
- {growthbook-1.3.0 → growthbook-1.4.0/growthbook.egg-info}/PKG-INFO +1 -1
- {growthbook-1.3.0 → growthbook-1.4.0}/setup.cfg +1 -1
- {growthbook-1.3.0 → growthbook-1.4.0}/tests/test_growthbook.py +6 -5
- {growthbook-1.3.0 → growthbook-1.4.0}/tests/test_growthbook_client.py +20 -10
- {growthbook-1.3.0 → growthbook-1.4.0}/tests/test_plugins.py +174 -3
- {growthbook-1.3.0 → growthbook-1.4.0}/LICENSE +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/MANIFEST.in +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/README.md +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/plugins/__init__.py +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/plugins/base.py +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/py.typed +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook.egg-info/SOURCES.txt +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/pyproject.toml +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/setup.py +0 -0
- {growthbook-1.3.0 → growthbook-1.4.0}/tests/conftest.py +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
+
from token import OP
|
|
4
5
|
# Only require typing_extensions if using Python 3.7 or earlier
|
|
5
6
|
if sys.version_info >= (3, 8):
|
|
6
7
|
from typing import TypedDict
|
|
@@ -8,7 +9,7 @@ else:
|
|
|
8
9
|
from typing_extensions import TypedDict
|
|
9
10
|
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
|
-
from typing import Any, Dict, List, Optional, Union, Set, Tuple
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Union, Set, Tuple
|
|
12
13
|
from enum import Enum
|
|
13
14
|
from abc import ABC, abstractmethod
|
|
14
15
|
|
|
@@ -400,6 +401,15 @@ class StackContext:
|
|
|
400
401
|
class FeatureRefreshStrategy(Enum):
|
|
401
402
|
STALE_WHILE_REVALIDATE = 'HTTP_REFRESH'
|
|
402
403
|
SERVER_SENT_EVENTS = 'SSE'
|
|
404
|
+
@dataclass
|
|
405
|
+
class UserContext:
|
|
406
|
+
# user_id: Optional[str] = None
|
|
407
|
+
url: str = ""
|
|
408
|
+
attributes: Dict[str, Any] = field(default_factory=dict)
|
|
409
|
+
groups: Dict[str, str] = field(default_factory=dict)
|
|
410
|
+
forced_variations: Dict[str, Any] = field(default_factory=dict)
|
|
411
|
+
overrides: Dict[str, Any] = field(default_factory=dict)
|
|
412
|
+
sticky_bucket_assignment_docs: Dict[str, Any] = field(default_factory=dict)
|
|
403
413
|
|
|
404
414
|
@dataclass
|
|
405
415
|
class Options:
|
|
@@ -415,17 +425,9 @@ class Options:
|
|
|
415
425
|
refresh_strategy: Optional[FeatureRefreshStrategy] = FeatureRefreshStrategy.STALE_WHILE_REVALIDATE
|
|
416
426
|
sticky_bucket_service: Optional[AbstractStickyBucketService] = None
|
|
417
427
|
sticky_bucket_identifier_attributes: Optional[List[str]] = None
|
|
418
|
-
on_experiment_viewed=None
|
|
428
|
+
on_experiment_viewed: Optional[Callable[[Experiment, Result, Optional[UserContext]], None]] = None
|
|
429
|
+
tracking_plugins: Optional[List[Any]] = None
|
|
419
430
|
|
|
420
|
-
@dataclass
|
|
421
|
-
class UserContext:
|
|
422
|
-
# user_id: Optional[str] = None
|
|
423
|
-
url: str = ""
|
|
424
|
-
attributes: Dict[str, Any] = field(default_factory=dict)
|
|
425
|
-
groups: Dict[str, str] = field(default_factory=dict)
|
|
426
|
-
forced_variations: Dict[str, Any] = field(default_factory=dict)
|
|
427
|
-
overrides: Dict[str, Any] = field(default_factory=dict)
|
|
428
|
-
sticky_bucket_assignment_docs: Dict[str, Any] = field(default_factory=dict)
|
|
429
431
|
|
|
430
432
|
@dataclass
|
|
431
433
|
class GlobalContext:
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
|
|
5
5
|
from urllib.parse import urlparse, parse_qs
|
|
6
6
|
from typing import Callable, Optional, Any, Set, Tuple, List, Dict
|
|
7
|
-
from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, VariationMeta
|
|
7
|
+
from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, UserContext, VariationMeta
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger("growthbook.core")
|
|
@@ -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, UserContext], 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)
|
|
@@ -633,7 +634,7 @@ def _get_sticky_bucket_variation(
|
|
|
633
634
|
def run_experiment(experiment: Experiment,
|
|
634
635
|
featureId: Optional[str] = None,
|
|
635
636
|
evalContext: EvaluationContext = None,
|
|
636
|
-
tracking_cb: Callable[[Experiment, Result], None] = None
|
|
637
|
+
tracking_cb: Callable[[Experiment, Result, UserContext], None] = None
|
|
637
638
|
) -> Result:
|
|
638
639
|
if evalContext is None:
|
|
639
640
|
raise ValueError("evalContext is required - run_experiment")
|
|
@@ -858,7 +859,7 @@ def run_experiment(experiment: Experiment,
|
|
|
858
859
|
|
|
859
860
|
# 14. Fire the tracking callback if set
|
|
860
861
|
if tracking_cb:
|
|
861
|
-
tracking_cb(experiment, result)
|
|
862
|
+
tracking_cb(experiment, result, evalContext.user)
|
|
862
863
|
|
|
863
864
|
# 15. Return the result
|
|
864
865
|
logger.debug("Assigned variation %d in experiment %s", assigned, experiment.key)
|
|
@@ -671,7 +671,7 @@ class GrowthBook(object):
|
|
|
671
671
|
return self.get_feature_value(key, fallback)
|
|
672
672
|
|
|
673
673
|
def get_feature_value(self, key: str, fallback):
|
|
674
|
-
res = self.
|
|
674
|
+
res = self.eval_feature(key)
|
|
675
675
|
return res.value if res.value is not None else fallback
|
|
676
676
|
|
|
677
677
|
# @deprecated, use eval_feature
|
|
@@ -708,7 +708,8 @@ class GrowthBook(object):
|
|
|
708
708
|
def eval_feature(self, key: str) -> FeatureResult:
|
|
709
709
|
return core_eval_feature(key=key,
|
|
710
710
|
evalContext=self._get_eval_context(),
|
|
711
|
-
callback_subscription=self._fireSubscriptions
|
|
711
|
+
callback_subscription=self._fireSubscriptions,
|
|
712
|
+
tracking_cb=self._track
|
|
712
713
|
)
|
|
713
714
|
|
|
714
715
|
# @deprecated, use get_all_results
|
|
@@ -752,7 +753,7 @@ class GrowthBook(object):
|
|
|
752
753
|
self._subscriptions.add(callback)
|
|
753
754
|
return lambda: self._subscriptions.remove(callback)
|
|
754
755
|
|
|
755
|
-
def _track(self, experiment: Experiment, result: Result) -> None:
|
|
756
|
+
def _track(self, experiment: Experiment, result: Result, user_context: UserContext) -> None:
|
|
756
757
|
if not self._trackingCallback:
|
|
757
758
|
return None
|
|
758
759
|
key = (
|
|
@@ -763,7 +764,7 @@ class GrowthBook(object):
|
|
|
763
764
|
)
|
|
764
765
|
if not self._tracked.get(key):
|
|
765
766
|
try:
|
|
766
|
-
self._trackingCallback(experiment=experiment, result=result)
|
|
767
|
+
self._trackingCallback(experiment=experiment, result=result, user_context=user_context)
|
|
767
768
|
self._tracked[key] = True
|
|
768
769
|
except Exception:
|
|
769
770
|
pass
|
|
@@ -317,6 +317,10 @@ class GrowthBookClient:
|
|
|
317
317
|
}
|
|
318
318
|
self._sticky_bucket_cache_lock = False
|
|
319
319
|
|
|
320
|
+
# Plugin support
|
|
321
|
+
self._tracking_plugins: List[Any] = self.options.tracking_plugins or []
|
|
322
|
+
self._initialized_plugins: List[Any] = []
|
|
323
|
+
|
|
320
324
|
self._features_repository = (
|
|
321
325
|
EnhancedFeatureRepository(
|
|
322
326
|
self.options.api_host or "https://cdn.growthbook.io",
|
|
@@ -330,9 +334,12 @@ class GrowthBookClient:
|
|
|
330
334
|
|
|
331
335
|
self._global_context: Optional[GlobalContext] = None
|
|
332
336
|
self._context_lock = asyncio.Lock()
|
|
337
|
+
|
|
338
|
+
# Initialize plugins
|
|
339
|
+
self._initialize_plugins()
|
|
333
340
|
|
|
334
341
|
|
|
335
|
-
def _track(self, experiment: Experiment, result: Result) -> None:
|
|
342
|
+
def _track(self, experiment: Experiment, result: Result, user_context: UserContext) -> None:
|
|
336
343
|
"""Thread-safe tracking implementation"""
|
|
337
344
|
if not self.options.on_experiment_viewed:
|
|
338
345
|
return
|
|
@@ -348,7 +355,7 @@ class GrowthBookClient:
|
|
|
348
355
|
with self._tracked_lock:
|
|
349
356
|
if not self._tracked.get(key):
|
|
350
357
|
try:
|
|
351
|
-
self.options.on_experiment_viewed(experiment
|
|
358
|
+
self.options.on_experiment_viewed(experiment, result, user_context)
|
|
352
359
|
self._tracked[key] = True
|
|
353
360
|
except Exception:
|
|
354
361
|
logger.exception("Error in tracking callback")
|
|
@@ -492,25 +499,25 @@ class GrowthBookClient:
|
|
|
492
499
|
"""Evaluate a feature with proper async context management"""
|
|
493
500
|
async with self._context_lock:
|
|
494
501
|
context = await self.create_evaluation_context(user_context)
|
|
495
|
-
result = core_eval_feature(key=key, evalContext=context)
|
|
502
|
+
result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
|
|
496
503
|
return result
|
|
497
504
|
|
|
498
505
|
async def is_on(self, key: str, user_context: UserContext) -> bool:
|
|
499
506
|
"""Check if a feature is enabled with proper async context management"""
|
|
500
507
|
async with self._context_lock:
|
|
501
508
|
context = await self.create_evaluation_context(user_context)
|
|
502
|
-
return core_eval_feature(key=key, evalContext=context).on
|
|
509
|
+
return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).on
|
|
503
510
|
|
|
504
511
|
async def is_off(self, key: str, user_context: UserContext) -> bool:
|
|
505
512
|
"""Check if a feature is set to off with proper async context management"""
|
|
506
513
|
async with self._context_lock:
|
|
507
514
|
context = await self.create_evaluation_context(user_context)
|
|
508
|
-
return core_eval_feature(key=key, evalContext=context).off
|
|
515
|
+
return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).off
|
|
509
516
|
|
|
510
517
|
async def get_feature_value(self, key: str, fallback: Any, user_context: UserContext) -> Any:
|
|
511
518
|
async with self._context_lock:
|
|
512
519
|
context = await self.create_evaluation_context(user_context)
|
|
513
|
-
result = core_eval_feature(key=key, evalContext=context)
|
|
520
|
+
result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
|
|
514
521
|
return result.value if result.value is not None else fallback
|
|
515
522
|
|
|
516
523
|
async def run(self, experiment: Experiment, user_context: UserContext) -> Result:
|
|
@@ -539,4 +546,37 @@ class GrowthBookClient:
|
|
|
539
546
|
|
|
540
547
|
# Clear context
|
|
541
548
|
async with self._context_lock:
|
|
542
|
-
self._global_context = None
|
|
549
|
+
self._global_context = None
|
|
550
|
+
|
|
551
|
+
# Cleanup plugins
|
|
552
|
+
self._cleanup_plugins()
|
|
553
|
+
|
|
554
|
+
def _initialize_plugins(self) -> None:
|
|
555
|
+
"""Initialize all tracking plugins with this GrowthBookClient instance."""
|
|
556
|
+
for plugin in self._tracking_plugins:
|
|
557
|
+
try:
|
|
558
|
+
if hasattr(plugin, 'initialize'):
|
|
559
|
+
# Plugin is a class instance with initialize method
|
|
560
|
+
plugin.initialize(self)
|
|
561
|
+
self._initialized_plugins.append(plugin)
|
|
562
|
+
logger.debug(f"Initialized plugin: {plugin.__class__.__name__}")
|
|
563
|
+
elif callable(plugin):
|
|
564
|
+
# Plugin is a callable function
|
|
565
|
+
plugin(self)
|
|
566
|
+
self._initialized_plugins.append(plugin)
|
|
567
|
+
logger.debug(f"Initialized callable plugin: {plugin.__name__}")
|
|
568
|
+
else:
|
|
569
|
+
logger.warning(f"Plugin {plugin} is neither callable nor has initialize method")
|
|
570
|
+
except Exception as e:
|
|
571
|
+
logger.error(f"Failed to initialize plugin {plugin}: {e}")
|
|
572
|
+
|
|
573
|
+
def _cleanup_plugins(self) -> None:
|
|
574
|
+
"""Cleanup all initialized plugins."""
|
|
575
|
+
for plugin in self._initialized_plugins:
|
|
576
|
+
try:
|
|
577
|
+
if hasattr(plugin, 'cleanup'):
|
|
578
|
+
plugin.cleanup()
|
|
579
|
+
logger.debug(f"Cleaned up plugin: {plugin.__class__.__name__}")
|
|
580
|
+
except Exception as e:
|
|
581
|
+
logger.error(f"Error cleaning up plugin {plugin}: {e}")
|
|
582
|
+
self._initialized_plugins.clear()
|
|
@@ -2,14 +2,16 @@ import json
|
|
|
2
2
|
import logging
|
|
3
3
|
import threading
|
|
4
4
|
import time
|
|
5
|
-
from typing import Dict, Any, Optional, List, Callable
|
|
5
|
+
from typing import Dict, Any, Optional, List, Callable, TYPE_CHECKING
|
|
6
6
|
from .base import GrowthBookPlugin
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
import requests # type: ignore
|
|
10
|
+
else:
|
|
11
|
+
try:
|
|
12
|
+
import requests # type: ignore
|
|
13
|
+
except ImportError:
|
|
14
|
+
requests = None
|
|
13
15
|
|
|
14
16
|
logger = logging.getLogger("growthbook.plugins.growthbook_tracking")
|
|
15
17
|
|
|
@@ -88,22 +90,43 @@ class GrowthBookTrackingPlugin(GrowthBookPlugin):
|
|
|
88
90
|
super().cleanup()
|
|
89
91
|
|
|
90
92
|
def _setup_experiment_tracking(self, gb_instance) -> None:
|
|
91
|
-
"""Setup experiment tracking."""
|
|
92
|
-
original_callback = getattr(gb_instance, '_trackingCallback', None)
|
|
93
|
+
"""Setup experiment tracking for both legacy and async clients."""
|
|
93
94
|
|
|
94
|
-
def tracking_wrapper(experiment, result):
|
|
95
|
+
def tracking_wrapper(experiment, result, user_context=None):
|
|
95
96
|
# Track to ingestor
|
|
96
97
|
self._track_experiment_viewed(experiment, result)
|
|
97
98
|
|
|
98
99
|
# Call additional callback
|
|
99
100
|
if self.additional_callback:
|
|
100
|
-
self._safe_execute(self.additional_callback, experiment, result)
|
|
101
|
+
self._safe_execute(self.additional_callback, experiment, result, user_context)
|
|
102
|
+
|
|
103
|
+
# Check if it's the legacy GrowthBook client (has _trackingCallback)
|
|
104
|
+
if hasattr(gb_instance, '_trackingCallback'):
|
|
105
|
+
# Legacy GrowthBook client
|
|
106
|
+
original_callback = getattr(gb_instance, '_trackingCallback', None)
|
|
107
|
+
|
|
108
|
+
def legacy_wrapper(experiment, result, user_context=None):
|
|
109
|
+
tracking_wrapper(experiment, result, user_context)
|
|
110
|
+
# Call original callback
|
|
111
|
+
if original_callback:
|
|
112
|
+
self._safe_execute(original_callback, experiment, result, user_context)
|
|
113
|
+
|
|
114
|
+
gb_instance._trackingCallback = legacy_wrapper
|
|
115
|
+
|
|
116
|
+
elif hasattr(gb_instance, 'options') and hasattr(gb_instance.options, 'on_experiment_viewed'):
|
|
117
|
+
# New GrowthBookClient (async)
|
|
118
|
+
original_callback = gb_instance.options.on_experiment_viewed
|
|
119
|
+
|
|
120
|
+
def async_wrapper(experiment, result, user_context):
|
|
121
|
+
tracking_wrapper(experiment, result, user_context)
|
|
122
|
+
# Call original callback
|
|
123
|
+
if original_callback:
|
|
124
|
+
self._safe_execute(original_callback, experiment, result, user_context)
|
|
101
125
|
|
|
102
|
-
|
|
103
|
-
if original_callback:
|
|
104
|
-
self._safe_execute(original_callback, experiment, result)
|
|
126
|
+
gb_instance.options.on_experiment_viewed = async_wrapper
|
|
105
127
|
|
|
106
|
-
|
|
128
|
+
else:
|
|
129
|
+
self.logger.warning("_trackingCallback or on_experiment_viewed properties not found - tracking may not work properly")
|
|
107
130
|
|
|
108
131
|
def _setup_feature_tracking(self, gb_instance):
|
|
109
132
|
"""Setup feature evaluation tracking."""
|
|
@@ -77,6 +77,7 @@ class RequestContextPlugin(GrowthBookPlugin):
|
|
|
77
77
|
self.client_side_attributes = client_side_attributes
|
|
78
78
|
self.extract_utm = extract_utm
|
|
79
79
|
self.extract_user_agent = extract_user_agent
|
|
80
|
+
self._extracted_attributes: Dict[str, Any] = {}
|
|
80
81
|
|
|
81
82
|
def initialize(self, gb_instance) -> None:
|
|
82
83
|
"""Initialize plugin - extract attributes from request context."""
|
|
@@ -87,18 +88,33 @@ class RequestContextPlugin(GrowthBookPlugin):
|
|
|
87
88
|
request_attributes = self._extract_all_attributes()
|
|
88
89
|
|
|
89
90
|
if request_attributes:
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
91
|
+
# Check client type and merge attributes accordingly
|
|
92
|
+
if hasattr(gb_instance, 'get_attributes') and hasattr(gb_instance, 'set_attributes'):
|
|
93
|
+
# Legacy GrowthBook client
|
|
94
|
+
current_attributes = gb_instance.get_attributes()
|
|
95
|
+
merged_attributes = {**request_attributes, **current_attributes}
|
|
96
|
+
gb_instance.set_attributes(merged_attributes)
|
|
97
|
+
self.logger.info(f"Extracted {len(request_attributes)} request attributes for legacy client")
|
|
98
|
+
|
|
99
|
+
elif hasattr(gb_instance, 'options'):
|
|
100
|
+
# New GrowthBookClient - store attributes for future use
|
|
101
|
+
# Note: GrowthBookClient doesn't have get/set_attributes, but we can store
|
|
102
|
+
# the extracted attributes for potential future use or logging
|
|
103
|
+
self._extracted_attributes = request_attributes
|
|
104
|
+
self.logger.info(f"Extracted {len(request_attributes)} request attributes for async client (stored for reference)")
|
|
105
|
+
|
|
106
|
+
else:
|
|
107
|
+
self.logger.warning("Unknown client type - cannot set attributes")
|
|
96
108
|
else:
|
|
97
109
|
self.logger.debug("No request context available")
|
|
98
110
|
|
|
99
111
|
except Exception as e:
|
|
100
112
|
self.logger.error(f"Failed to extract request attributes: {e}")
|
|
101
113
|
|
|
114
|
+
def get_extracted_attributes(self) -> Dict[str, Any]:
|
|
115
|
+
"""Get the attributes extracted from request context."""
|
|
116
|
+
return self._extracted_attributes.copy()
|
|
117
|
+
|
|
102
118
|
def _extract_all_attributes(self) -> Dict[str, Any]:
|
|
103
119
|
"""Extract all available attributes from request context."""
|
|
104
120
|
attributes = {}
|
|
@@ -177,8 +177,8 @@ def test_stickyBucket(stickyBucket_data):
|
|
|
177
177
|
def getTrackingMock(gb: GrowthBook):
|
|
178
178
|
calls = []
|
|
179
179
|
|
|
180
|
-
def track(experiment, result):
|
|
181
|
-
return calls.append([experiment, result])
|
|
180
|
+
def track(experiment, result, user_context):
|
|
181
|
+
return calls.append([experiment, result, user_context])
|
|
182
182
|
|
|
183
183
|
gb._trackingCallback = track
|
|
184
184
|
return lambda: calls
|
|
@@ -207,9 +207,10 @@ def test_tracking():
|
|
|
207
207
|
|
|
208
208
|
calls = getMockedCalls()
|
|
209
209
|
assert len(calls) == 3
|
|
210
|
-
|
|
211
|
-
assert calls[
|
|
212
|
-
assert calls[
|
|
210
|
+
# validate experiment and result only
|
|
211
|
+
assert calls[0][0] == exp1 and calls[0][1] == res1
|
|
212
|
+
assert calls[1][0] == exp2 and calls[1][1] == res4
|
|
213
|
+
assert calls[2][0] == exp2 and calls[2][1] == res5
|
|
213
214
|
|
|
214
215
|
gb.destroy()
|
|
215
216
|
|
|
@@ -657,8 +657,8 @@ async def getTrackingMock(client: GrowthBookClient):
|
|
|
657
657
|
"""Helper function to mock tracking for tests"""
|
|
658
658
|
calls = []
|
|
659
659
|
|
|
660
|
-
def track(experiment, result):
|
|
661
|
-
calls.append([experiment, result])
|
|
660
|
+
def track(experiment, result, user_context):
|
|
661
|
+
calls.append([experiment, result, user_context])
|
|
662
662
|
|
|
663
663
|
client.options.on_experiment_viewed = track
|
|
664
664
|
return lambda: calls
|
|
@@ -713,13 +713,24 @@ async def test_tracking():
|
|
|
713
713
|
# Verify tracking calls
|
|
714
714
|
calls = getMockedCalls()
|
|
715
715
|
assert len(calls) == 3, "Expected exactly 3 tracking calls"
|
|
716
|
-
assert calls[0] == [exp1, res1], "First tracking call mismatch"
|
|
717
|
-
assert calls[1] == [exp2, res4], "Second tracking call mismatch"
|
|
718
|
-
assert calls[2] == [exp2, res5], "Third tracking call mismatch"
|
|
716
|
+
assert calls[0] == [exp1, res1, user_context], "First tracking call mismatch"
|
|
717
|
+
assert calls[1] == [exp2, res4, user_context], "Second tracking call mismatch"
|
|
718
|
+
assert calls[2] == [exp2, res5, user_context], "Third tracking call mismatch"
|
|
719
719
|
|
|
720
720
|
finally:
|
|
721
721
|
await client.close()
|
|
722
722
|
|
|
723
|
+
async def getFailedTrackingMock(client: GrowthBookClient):
|
|
724
|
+
"""Helper function to mock tracking for tests"""
|
|
725
|
+
calls = []
|
|
726
|
+
# Set up tracking callback that raises an error
|
|
727
|
+
def failing_track(experiment, result, user_context):
|
|
728
|
+
calls.append([experiment, result, user_context])
|
|
729
|
+
raise Exception("Tracking failed")
|
|
730
|
+
|
|
731
|
+
client.options.on_experiment_viewed = failing_track
|
|
732
|
+
return lambda: calls
|
|
733
|
+
|
|
723
734
|
@pytest.mark.asyncio
|
|
724
735
|
async def test_handles_tracking_errors():
|
|
725
736
|
"""Test graceful handling of tracking callback errors"""
|
|
@@ -729,11 +740,7 @@ async def test_handles_tracking_errors():
|
|
|
729
740
|
enabled=True
|
|
730
741
|
))
|
|
731
742
|
|
|
732
|
-
|
|
733
|
-
def failing_track(experiment, result):
|
|
734
|
-
raise Exception("Tracking failed")
|
|
735
|
-
|
|
736
|
-
client.options.on_experiment_viewed = failing_track
|
|
743
|
+
getMockedTrackingCalls = await getFailedTrackingMock(client)
|
|
737
744
|
|
|
738
745
|
# Create test experiment
|
|
739
746
|
exp = Experiment(
|
|
@@ -757,5 +764,8 @@ async def test_handles_tracking_errors():
|
|
|
757
764
|
result = await client.run(exp, user_context)
|
|
758
765
|
assert result is not None, "Experiment should run despite tracking error"
|
|
759
766
|
|
|
767
|
+
calls = getMockedTrackingCalls()
|
|
768
|
+
assert len(calls) == 1, "Expected exactly 1 tracking call"
|
|
769
|
+
|
|
760
770
|
finally:
|
|
761
771
|
await client.close()
|
|
@@ -178,11 +178,12 @@ class TestTrackingPlugin(unittest.TestCase):
|
|
|
178
178
|
"""Test that tracking plugin calls callback when experiments run."""
|
|
179
179
|
tracked_experiments = []
|
|
180
180
|
|
|
181
|
-
def track_callback(experiment, result):
|
|
181
|
+
def track_callback(experiment, result, user_context):
|
|
182
182
|
tracked_experiments.append({
|
|
183
183
|
'key': experiment.key,
|
|
184
184
|
'value': result.value,
|
|
185
|
-
'inExperiment': result.inExperiment
|
|
185
|
+
'inExperiment': result.inExperiment,
|
|
186
|
+
'user_context': user_context
|
|
186
187
|
})
|
|
187
188
|
|
|
188
189
|
gb = GrowthBook(
|
|
@@ -254,4 +255,174 @@ class TestTrackingPlugin(unittest.TestCase):
|
|
|
254
255
|
self.assertEqual(flag_track['value'], True)
|
|
255
256
|
self.assertEqual(string_track['value'], 'hello')
|
|
256
257
|
|
|
257
|
-
gb.destroy()
|
|
258
|
+
gb.destroy()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class TestGrowthBookClientPlugins(unittest.TestCase):
|
|
262
|
+
"""Test plugin integration with GrowthBookClient (async client)."""
|
|
263
|
+
|
|
264
|
+
def setUp(self):
|
|
265
|
+
"""Set up test fixtures."""
|
|
266
|
+
self.tracked_events = []
|
|
267
|
+
|
|
268
|
+
def tearDown(self):
|
|
269
|
+
"""Clean up after tests."""
|
|
270
|
+
self.tracked_events.clear()
|
|
271
|
+
# Clear request context
|
|
272
|
+
clear_request_context()
|
|
273
|
+
|
|
274
|
+
def test_plugins_work_with_both_client_types(self):
|
|
275
|
+
"""Test that plugins work with both legacy GrowthBook and async GrowthBookClient."""
|
|
276
|
+
import asyncio
|
|
277
|
+
from unittest.mock import patch, AsyncMock
|
|
278
|
+
from growthbook.growthbook_client import GrowthBookClient
|
|
279
|
+
from growthbook.common_types import Options, Experiment, UserContext
|
|
280
|
+
from growthbook import GrowthBook
|
|
281
|
+
|
|
282
|
+
# Set up request context for testing
|
|
283
|
+
mock_request = MockDjangoRequest()
|
|
284
|
+
set_request_context(mock_request)
|
|
285
|
+
|
|
286
|
+
async def test_async_client():
|
|
287
|
+
"""Test with async GrowthBookClient."""
|
|
288
|
+
tracked_events = []
|
|
289
|
+
|
|
290
|
+
def track_callback(experiment, result, user_context):
|
|
291
|
+
tracked_events.append({
|
|
292
|
+
'client_type': 'async',
|
|
293
|
+
'experiment_key': experiment.key,
|
|
294
|
+
'result_value': result.value,
|
|
295
|
+
'user_id': user_context.attributes.get('id') if user_context else None
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
# Create plugins
|
|
299
|
+
tracking_plugin = growthbook_tracking_plugin(
|
|
300
|
+
ingestor_host="https://test.growthbook.io",
|
|
301
|
+
additional_callback=track_callback,
|
|
302
|
+
batch_size=1
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
context_plugin = request_context_plugin(
|
|
306
|
+
extract_utm=True,
|
|
307
|
+
extract_user_agent=True
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# Create async client with plugins
|
|
311
|
+
client = GrowthBookClient(
|
|
312
|
+
Options(
|
|
313
|
+
api_host="https://cdn.growthbook.io",
|
|
314
|
+
client_key="test-key",
|
|
315
|
+
tracking_plugins=[context_plugin, tracking_plugin]
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
# Verify plugins initialized
|
|
321
|
+
self.assertEqual(len(client._initialized_plugins), 2)
|
|
322
|
+
self.assertTrue(tracking_plugin.is_initialized())
|
|
323
|
+
self.assertTrue(context_plugin.is_initialized())
|
|
324
|
+
|
|
325
|
+
# Verify context plugin extracted attributes (stored for async client)
|
|
326
|
+
extracted_attrs = context_plugin.get_extracted_attributes()
|
|
327
|
+
self.assertIn('utmSource', extracted_attrs)
|
|
328
|
+
self.assertEqual(extracted_attrs['utmSource'], 'google')
|
|
329
|
+
|
|
330
|
+
with patch('growthbook.FeatureRepository.load_features_async',
|
|
331
|
+
new_callable=AsyncMock, return_value={"features": {}, "savedGroups": {}}), \
|
|
332
|
+
patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh',
|
|
333
|
+
new_callable=AsyncMock), \
|
|
334
|
+
patch('growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh',
|
|
335
|
+
new_callable=AsyncMock), \
|
|
336
|
+
patch('requests.post') as mock_post:
|
|
337
|
+
|
|
338
|
+
mock_post.return_value.status_code = 200
|
|
339
|
+
|
|
340
|
+
await client.initialize()
|
|
341
|
+
|
|
342
|
+
# Run experiment
|
|
343
|
+
exp = Experiment(key="dual-client-test", variations=[0, 1])
|
|
344
|
+
user_context = UserContext(attributes={"id": "async-user"})
|
|
345
|
+
|
|
346
|
+
result = await client.run(exp, user_context)
|
|
347
|
+
await asyncio.sleep(0.1) # Wait for async tracking
|
|
348
|
+
|
|
349
|
+
# Verify tracking worked
|
|
350
|
+
self.assertEqual(len(tracked_events), 1)
|
|
351
|
+
self.assertEqual(tracked_events[0]['client_type'], 'async')
|
|
352
|
+
self.assertEqual(tracked_events[0]['experiment_key'], 'dual-client-test')
|
|
353
|
+
|
|
354
|
+
finally:
|
|
355
|
+
await client.close()
|
|
356
|
+
|
|
357
|
+
return tracked_events
|
|
358
|
+
|
|
359
|
+
def test_legacy_client():
|
|
360
|
+
"""Test with legacy GrowthBook client."""
|
|
361
|
+
tracked_events = []
|
|
362
|
+
|
|
363
|
+
def track_callback(experiment, result, user_context):
|
|
364
|
+
tracked_events.append({
|
|
365
|
+
'client_type': 'legacy',
|
|
366
|
+
'experiment_key': experiment.key,
|
|
367
|
+
'result_value': result.value,
|
|
368
|
+
'user_id': user_context.attributes.get('id') if user_context else None
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
# Create plugins (same instances can be reused)
|
|
372
|
+
tracking_plugin = growthbook_tracking_plugin(
|
|
373
|
+
ingestor_host="https://test.growthbook.io",
|
|
374
|
+
additional_callback=track_callback,
|
|
375
|
+
batch_size=1
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
context_plugin = request_context_plugin(
|
|
379
|
+
extract_utm=True,
|
|
380
|
+
extract_user_agent=True
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Create legacy client with plugins
|
|
384
|
+
gb = GrowthBook(
|
|
385
|
+
attributes={"id": "legacy-user"},
|
|
386
|
+
plugins=[context_plugin, tracking_plugin]
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
# Verify plugins initialized
|
|
391
|
+
self.assertTrue(tracking_plugin.is_initialized())
|
|
392
|
+
self.assertTrue(context_plugin.is_initialized())
|
|
393
|
+
|
|
394
|
+
# Verify context plugin set attributes on legacy client
|
|
395
|
+
attrs = gb.get_attributes()
|
|
396
|
+
self.assertIn('utmSource', attrs)
|
|
397
|
+
self.assertEqual(attrs['utmSource'], 'google')
|
|
398
|
+
self.assertEqual(attrs['id'], 'legacy-user') # Original should be preserved
|
|
399
|
+
|
|
400
|
+
with patch('requests.post') as mock_post:
|
|
401
|
+
mock_post.return_value.status_code = 200
|
|
402
|
+
|
|
403
|
+
# Run experiment
|
|
404
|
+
exp = Experiment(key="dual-client-test", variations=[0, 1])
|
|
405
|
+
result = gb.run(exp)
|
|
406
|
+
|
|
407
|
+
# Verify tracking worked
|
|
408
|
+
self.assertEqual(len(tracked_events), 1)
|
|
409
|
+
self.assertEqual(tracked_events[0]['client_type'], 'legacy')
|
|
410
|
+
self.assertEqual(tracked_events[0]['experiment_key'], 'dual-client-test')
|
|
411
|
+
|
|
412
|
+
finally:
|
|
413
|
+
gb.destroy()
|
|
414
|
+
|
|
415
|
+
return tracked_events
|
|
416
|
+
|
|
417
|
+
# Test both client types
|
|
418
|
+
async_events = asyncio.run(test_async_client())
|
|
419
|
+
legacy_events = test_legacy_client()
|
|
420
|
+
|
|
421
|
+
# Verify both worked
|
|
422
|
+
self.assertEqual(len(async_events), 1)
|
|
423
|
+
self.assertEqual(len(legacy_events), 1)
|
|
424
|
+
self.assertEqual(async_events[0]['client_type'], 'async')
|
|
425
|
+
self.assertEqual(legacy_events[0]['client_type'], 'legacy')
|
|
426
|
+
|
|
427
|
+
# Clean up request context
|
|
428
|
+
clear_request_context()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|