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.
Files changed (26) hide show
  1. {growthbook-1.3.0/growthbook.egg-info → growthbook-1.4.0}/PKG-INFO +1 -1
  2. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/__init__.py +1 -1
  3. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/common_types.py +13 -11
  4. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/core.py +6 -5
  5. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/growthbook.py +5 -4
  6. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/growthbook_client.py +47 -7
  7. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/plugins/growthbook_tracking.py +37 -14
  8. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/plugins/request_context.py +22 -6
  9. {growthbook-1.3.0 → growthbook-1.4.0/growthbook.egg-info}/PKG-INFO +1 -1
  10. {growthbook-1.3.0 → growthbook-1.4.0}/setup.cfg +1 -1
  11. {growthbook-1.3.0 → growthbook-1.4.0}/tests/test_growthbook.py +6 -5
  12. {growthbook-1.3.0 → growthbook-1.4.0}/tests/test_growthbook_client.py +20 -10
  13. {growthbook-1.3.0 → growthbook-1.4.0}/tests/test_plugins.py +174 -3
  14. {growthbook-1.3.0 → growthbook-1.4.0}/LICENSE +0 -0
  15. {growthbook-1.3.0 → growthbook-1.4.0}/MANIFEST.in +0 -0
  16. {growthbook-1.3.0 → growthbook-1.4.0}/README.md +0 -0
  17. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/plugins/__init__.py +0 -0
  18. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/plugins/base.py +0 -0
  19. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook/py.typed +0 -0
  20. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook.egg-info/SOURCES.txt +0 -0
  21. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook.egg-info/dependency_links.txt +0 -0
  22. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook.egg-info/requires.txt +0 -0
  23. {growthbook-1.3.0 → growthbook-1.4.0}/growthbook.egg-info/top_level.txt +0 -0
  24. {growthbook-1.3.0 → growthbook-1.4.0}/pyproject.toml +0 -0
  25. {growthbook-1.3.0 → growthbook-1.4.0}/setup.py +0 -0
  26. {growthbook-1.3.0 → growthbook-1.4.0}/tests/conftest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.3.0
3
+ Version: 1.4.0
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,5 +18,5 @@ from .plugins import (
18
18
  )
19
19
 
20
20
  # x-release-please-start-version
21
- __version__ = "1.3.0"
21
+ __version__ = "1.4.0"
22
22
  # x-release-please-end
@@ -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.evalFeature(key)
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=experiment, result=result)
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
- requests: Any = None
9
- try:
10
- import requests
11
- except ImportError:
12
- pass
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
- # Call original callback
103
- if original_callback:
104
- self._safe_execute(original_callback, experiment, result)
126
+ gb_instance.options.on_experiment_viewed = async_wrapper
105
127
 
106
- gb_instance._trackingCallback = tracking_wrapper
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
- # 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")
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 = {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.3.0
3
+ Version: 1.4.0
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
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 1.3.0
2
+ current_version = 1.4.0
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -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
- assert calls[0] == [exp1, res1]
211
- assert calls[1] == [exp2, res4]
212
- assert calls[2] == [exp2, res5]
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
- # Set up tracking callback that raises an error
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