diff --git a/growthbook/common_types.py b/growthbook/common_types.py index c0a187b..15b9a5b 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -433,6 +433,7 @@ class Options: tracking_plugins: Optional[List[Any]] = None http_connect_timeout: Optional[int] = None http_read_timeout: Optional[int] = None + event_logger: Optional[Callable[..., Any]] = None @dataclass diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index c89bb37..f04a75d 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -758,6 +758,7 @@ def __init__( self._assigned: Dict[str, Any] = {} self._subscriptions: Set[Any] = set() self._is_updating_features = False + self._event_logger: Optional[Any] = None # support plugins self._plugins: List[Any] = plugins if plugins is not None else [] @@ -973,6 +974,7 @@ def destroy(self, timeout=10) -> None: self._assigned.clear() self._trackingCallback = None self._featureUsageCallback = None + self._event_logger = None self._forcedVariations.clear() self._overrides.clear() self._groups.clear() @@ -982,6 +984,40 @@ def destroy(self, timeout=10) -> None: except Exception as e: logger.warning(f"Error clearing internal state: {e}") + def set_event_logger(self, fn) -> None: + """Register a callable that will be invoked by log_event. + + The callable receives (event_name: str, properties: dict, user_context: UserContext). + Typically set by GrowthBookTrackingPlugin rather than called directly. + """ + self._event_logger = fn + + def log_event(self, event_name: str, properties: Optional[Dict[str, Any]] = None) -> None: + """Log a custom event to the GrowthBook ingestor. + + Requires GrowthBookTrackingPlugin to be configured; without it a warning + is emitted and the call is a no-op. + + Args: + event_name: Name of the event (e.g. ``"button_clicked"``). + properties: Optional dict of event-specific properties. + """ + if self._event_logger is None: + logger.warning( + "log_event called but no event logger is configured. " + "Add GrowthBookTrackingPlugin to enable event logging." + ) + return + # set_attributes() replaces self._attributes but does not write back to + # _user_ctx; _get_eval_context() does that lazily before every eval. + # Mirror the same sync here so log_event always sees current attributes. + self._user_ctx.attributes = self._attributes + self._user_ctx.url = self._url + try: + self._event_logger(event_name, properties or {}, self._user_ctx) + except Exception as e: + logger.exception("Error in event logger: %s", e) + def isOn(self, key: str) -> bool: warnings.warn("isOn is deprecated, use is_on instead", DeprecationWarning) return self.is_on(key) diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index 1ec8883..1aa07cb 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -440,6 +440,44 @@ def _fire_subscriptions(self, experiment: Experiment, result: Result) -> None: logger.exception("Error in subscription callback") + def set_event_logger(self, fn) -> None: + """Register a callable that will be invoked by log_event. + + The callable receives (event_name: str, properties: dict, user_context: UserContext). + Typically set by GrowthBookTrackingPlugin rather than called directly. + """ + self.options.event_logger = fn + + async def log_event( + self, + event_name: str, + properties: Optional[Dict[str, Any]] = None, + user_context: Optional[UserContext] = None, + ) -> None: + """Log a custom event to the GrowthBook ingestor. + + Requires GrowthBookTrackingPlugin to be configured; without it a warning + is emitted and the call is a no-op. + + Args: + event_name: Name of the event (e.g. ``"button_clicked"``). + properties: Optional dict of event-specific properties. + user_context: User context for the event; uses an empty context if omitted. + """ + if not self.options.event_logger: + logger.warning( + "log_event called but no event logger is configured. " + "Add GrowthBookTrackingPlugin to enable event logging." + ) + return + ctx = user_context or UserContext() + try: + result = self.options.event_logger(event_name, properties or {}, ctx) + if asyncio.iscoroutine(result): + await result + except Exception as e: + logger.exception("Error in event logger: %s", e) + async def set_features(self, features: dict) -> None: await self._feature_update_callback({"features": features}) diff --git a/growthbook/plugins/growthbook_tracking.py b/growthbook/plugins/growthbook_tracking.py index 742406c..ff7d166 100644 --- a/growthbook/plugins/growthbook_tracking.py +++ b/growthbook/plugins/growthbook_tracking.py @@ -15,271 +15,360 @@ logger = logging.getLogger("growthbook.plugins.growthbook_tracking") +# Attribute keys promoted to top-level fields in the ingestor payload. +# All remaining attributes are stored in context_json. +_TOP_LEVEL_ATTR_KEYS = { + "user_id", + "device_id", + "anonymous_id", + "id", + "page_id", + "session_id", + "utmCampaign", + "utmContent", + "utmMedium", + "utmSource", + "utmTerm", + "pageTitle", +} + + +def _parse_string(value) -> Optional[str]: + return value if isinstance(value, str) else None + + +def _build_event_payload( + event_name: str, + properties: Dict[str, Any], + attributes: Dict[str, Any], + url: str, + sdk_version: str, +) -> Dict[str, Any]: + """Build an ingestor payload that matches the JS SDK EventPayload shape.""" + nested = {k: v for k, v in attributes.items() if k not in _TOP_LEVEL_ATTR_KEYS} + + payload: Dict[str, Any] = { + "event_name": event_name, + "properties_json": properties or {}, + "sdk_language": "python", + "sdk_version": sdk_version, + "url": url or "", + "context_json": nested, + "user_id": _parse_string(attributes.get("user_id")), + "device_id": _parse_string( + attributes.get("device_id") + or attributes.get("anonymous_id") + or attributes.get("id") + ), + "page_id": _parse_string(attributes.get("page_id")), + "session_id": _parse_string(attributes.get("session_id")), + } + + # Optional UTM / page_title fields — omit rather than null + for src_key, dest_key in [ + ("utmCampaign", "utm_campaign"), + ("utmContent", "utm_content"), + ("utmMedium", "utm_medium"), + ("utmSource", "utm_source"), + ("utmTerm", "utm_term"), + ("pageTitle", "page_title"), + ]: + value = _parse_string(attributes.get(src_key)) + if value is not None: + payload[dest_key] = value + + return payload + class GrowthBookTrackingPlugin(GrowthBookPlugin): """ GrowthBook tracking plugin for Built-in Warehouse. - - This plugin automatically tracks "Experiment Viewed" and "Feature Evaluated" - events to GrowthBook's built-in data warehouse. + + Automatically tracks "Experiment Viewed" and "Feature Evaluated" events + and enables custom event logging via ``gb.log_event()``, all sent to + GrowthBook's ingestor pipeline. """ - + def __init__( self, - ingestor_host: str, + ingestor_host: str = "https://us1.gb-ingest.com", track_experiment_viewed: bool = True, track_feature_evaluated: bool = True, batch_size: int = 10, batch_timeout: float = 10.0, additional_callback: Optional[Callable] = None, - **options + **options, ): """ Initialize GrowthBook tracking plugin. - + Args: - ingestor_host: The GrowthBook ingestor endpoint - track_experiment_viewed: Whether to track experiment viewed events - track_feature_evaluated: Whether to track feature evaluated events - batch_size: Number of events to batch before sending - batch_timeout: Maximum time (seconds) to wait before sending a batch - additional_callback: Optional additional tracking callback + ingestor_host: Base URL of the GrowthBook ingestor (default: ``https://us1.gb-ingest.com``). + track_experiment_viewed: Whether to auto-track experiment viewed events. + track_feature_evaluated: Whether to auto-track feature evaluated events. + batch_size: Number of events to accumulate before flushing to the ingestor. + batch_timeout: Max seconds to wait before flushing a partial batch. + additional_callback: Optional extra callback invoked on experiment viewed events. """ super().__init__(**options) - + if not requests: - raise ImportError("requests library is required for GrowthBookTrackingPlugin. Install with: pip install requests") - - self.ingestor_host = ingestor_host.rstrip('/') + raise ImportError( + "requests library is required for GrowthBookTrackingPlugin. " + "Install with: pip install requests" + ) + + self.ingestor_host = ingestor_host.rstrip("/") self.track_experiment_viewed = track_experiment_viewed self.track_feature_evaluated = track_feature_evaluated self.batch_size = batch_size self.batch_timeout = batch_timeout self.additional_callback = additional_callback - - # batching + + # Batching state self._event_batch: List[Dict[str, Any]] = [] self._batch_lock = threading.Lock() self._flush_timer: Optional[threading.Timer] = None self._client_key: Optional[str] = None - + + # ------------------------------------------------------------------ + # Plugin lifecycle + # ------------------------------------------------------------------ + def initialize(self, gb_instance) -> None: - """Initialize plugin with GrowthBook instance.""" + """Initialize plugin with a GrowthBook instance.""" try: - self._client_key = getattr(gb_instance, '_client_key', '') - - # Hook into experiment tracking + self._client_key = getattr(gb_instance, "_client_key", "") or "" + if self.track_experiment_viewed: self._setup_experiment_tracking(gb_instance) - - # Hook into feature evaluation + if self.track_feature_evaluated: self._setup_feature_tracking(gb_instance) - + + # Wire log_event so custom events flow through the same pipeline + if hasattr(gb_instance, "set_event_logger"): + gb_instance.set_event_logger(self._handle_log_event) + self._set_initialized(gb_instance) - self.logger.info(f"Tracking enabled for {self.ingestor_host}") - + self.logger.info("Tracking enabled → %s", self.ingestor_host) + except Exception as e: - self.logger.error(f"Failed to initialize tracking plugin: {e}") - + self.logger.error("Failed to initialize tracking plugin: %s", e) + def cleanup(self) -> None: - """Cleanup plugin resources.""" + """Flush any pending events and release resources.""" self._flush_events() - if self._flush_timer: - self._flush_timer.cancel() + with self._batch_lock: + if self._flush_timer: + self._flush_timer.cancel() + self._flush_timer = None super().cleanup() - + + # ------------------------------------------------------------------ + # Experiment / feature auto-tracking (legacy hooks) + # ------------------------------------------------------------------ + def _setup_experiment_tracking(self, gb_instance) -> None: - """Setup experiment tracking for both legacy and async clients.""" - def tracking_wrapper(experiment, result, user_context=None): - # Track to ingestor self._track_experiment_viewed(experiment, result) - - # Call additional callback if self.additional_callback: self._safe_execute(self.additional_callback, experiment, result, user_context) - - # Check if it's the legacy GrowthBook client (has _trackingCallback) - if hasattr(gb_instance, '_trackingCallback'): - # Legacy GrowthBook client - original_callback = getattr(gb_instance, '_trackingCallback', None) - + + if hasattr(gb_instance, "_trackingCallback"): + original = gb_instance._trackingCallback + def legacy_wrapper(experiment, result, user_context=None): tracking_wrapper(experiment, result, user_context) - # Call original callback - if original_callback: - self._safe_execute(original_callback, experiment, result, user_context) - + if original: + self._safe_execute(original, experiment, result, user_context) + gb_instance._trackingCallback = legacy_wrapper - - elif hasattr(gb_instance, 'options') and hasattr(gb_instance.options, 'on_experiment_viewed'): - # New GrowthBookClient (async) - original_callback = gb_instance.options.on_experiment_viewed - + + elif hasattr(gb_instance, "options") and hasattr( + gb_instance.options, "on_experiment_viewed" + ): + original = gb_instance.options.on_experiment_viewed + def async_wrapper(experiment, result, user_context): tracking_wrapper(experiment, result, user_context) - # Call original callback - if original_callback: - self._safe_execute(original_callback, experiment, result, user_context) - + if original: + self._safe_execute(original, experiment, result, user_context) + gb_instance.options.on_experiment_viewed = async_wrapper - + else: - self.logger.warning("_trackingCallback or on_experiment_viewed properties not found - tracking may not work properly") - - def _setup_feature_tracking(self, gb_instance): - """Setup feature evaluation tracking.""" + self.logger.warning( + "_trackingCallback or on_experiment_viewed not found — " + "experiment tracking may not work" + ) + + def _setup_feature_tracking(self, gb_instance) -> None: original_eval_feature = gb_instance.eval_feature - + def eval_feature_wrapper(key: str, *args, **kwargs): result = original_eval_feature(key, *args, **kwargs) self._track_feature_evaluated(key, result, gb_instance) return result - + gb_instance.eval_feature = eval_feature_wrapper - + def _track_experiment_viewed(self, experiment, result) -> None: - """Track experiment viewed event.""" try: - # Build event data with all metadata - event_data = { - 'event_type': 'experiment_viewed', - 'timestamp': int(time.time() * 1000), - 'client_key': self._client_key, - 'sdk_language': 'python', - 'sdk_version': self._get_sdk_version(), - # Core experiment data - 'experiment_id': experiment.key, - 'variation_id': result.variationId, - 'variation_key': getattr(result, 'key', str(result.variationId)), - 'variation_value': result.value, - 'in_experiment': result.inExperiment, - 'hash_used': result.hashUsed, - 'hash_attribute': result.hashAttribute, - 'hash_value': result.hashValue, - } - - # Add optional metadata if available - if hasattr(experiment, 'name') and experiment.name: - event_data['experiment_name'] = experiment.name - if hasattr(result, 'featureId') and result.featureId: - event_data['feature_id'] = result.featureId - - self._add_event_to_batch(event_data) - + attrs: Dict[str, Any] = {} + if self._gb_instance is not None: + attrs = getattr(self._gb_instance, "_attributes", {}) or {} + + payload = _build_event_payload( + event_name="$$experiment_viewed", + properties={ + "experiment_id": experiment.key, + "variation_id": result.variationId, + "variation_key": getattr(result, "key", str(result.variationId)), + "in_experiment": result.inExperiment, + "hash_used": result.hashUsed, + "hash_attribute": result.hashAttribute, + "hash_value": result.hashValue, + }, + attributes=attrs, + url=getattr(self._gb_instance, "_url", "") if self._gb_instance else "", + sdk_version=self._get_sdk_version(), + ) + self._add_event_to_batch(payload) except Exception as e: - self.logger.error(f"Error tracking experiment: {e}") - + self.logger.error("Error tracking experiment: %s", e) + def _track_feature_evaluated(self, feature_key: str, result, gb_instance) -> None: - """Track feature evaluated event.""" try: - # Build event data with all metadata - event_data = { - 'event_type': 'feature_evaluated', - 'timestamp': int(time.time() * 1000), - 'client_key': self._client_key, - 'sdk_language': 'python', - 'sdk_version': self._get_sdk_version(), - # Core feature data - 'feature_key': feature_key, - 'feature_value': result.value, - 'source': result.source, - 'on': getattr(result, 'on', bool(result.value)), - 'off': getattr(result, 'off', not bool(result.value)), + attrs: Dict[str, Any] = getattr(gb_instance, "_attributes", {}) or {} + props: Dict[str, Any] = { + "feature_key": feature_key, + "feature_value": result.value, + "source": result.source, } - - # Add optional metadata if available - if hasattr(result, 'ruleId') and result.ruleId: - event_data['rule_id'] = result.ruleId - - # Add experiment info if feature came from experiment - if hasattr(result, 'experiment') and result.experiment: - event_data['experiment_id'] = result.experiment.key - if hasattr(result, 'experimentResult') and result.experimentResult: - event_data['variation_id'] = result.experimentResult.variationId - event_data['in_experiment'] = result.experimentResult.inExperiment - - self._add_event_to_batch(event_data) - + if hasattr(result, "ruleId") and result.ruleId: + props["rule_id"] = result.ruleId + if hasattr(result, "experiment") and result.experiment: + props["experiment_id"] = result.experiment.key + if hasattr(result, "experimentResult") and result.experimentResult: + props["variation_id"] = result.experimentResult.variationId + props["in_experiment"] = result.experimentResult.inExperiment + + payload = _build_event_payload( + event_name="$$feature_evaluated", + properties=props, + attributes=attrs, + url=getattr(gb_instance, "_url", ""), + sdk_version=self._get_sdk_version(), + ) + self._add_event_to_batch(payload) except Exception as e: - self.logger.error(f"Error tracking feature: {e}") - - def _add_event_to_batch(self, event_data: Dict[str, Any]) -> None: + self.logger.error("Error tracking feature: %s", e) + + # ------------------------------------------------------------------ + # log_event handler (custom events) + # ------------------------------------------------------------------ + + def _handle_log_event( + self, event_name: str, properties: Dict[str, Any], user_context + ) -> None: + """Called by GrowthBook.log_event / GrowthBookClient.log_event.""" + try: + attrs: Dict[str, Any] = getattr(user_context, "attributes", {}) or {} + url: str = getattr(user_context, "url", "") or "" + payload = _build_event_payload( + event_name=event_name, + properties=properties or {}, + attributes=attrs, + url=url, + sdk_version=self._get_sdk_version(), + ) + self._add_event_to_batch(payload) + except Exception as e: + self.logger.error("Error in event logger: %s", e) + + # ------------------------------------------------------------------ + # Batching helpers + # ------------------------------------------------------------------ + + def _add_event_to_batch(self, payload: Dict[str, Any]) -> None: with self._batch_lock: - self._event_batch.append(event_data) - - # Flush if batch is full + self._event_batch.append(payload) if len(self._event_batch) >= self.batch_size: self._flush_batch_locked() elif len(self._event_batch) == 1: - # Start timer for first event self._start_flush_timer() - + def _start_flush_timer(self) -> None: - """Start flush timer.""" if self._flush_timer: self._flush_timer.cancel() - self._flush_timer = threading.Timer(self.batch_timeout, self._flush_events) + self._flush_timer.daemon = True self._flush_timer.start() - + def _flush_events(self) -> None: - """Flush events with lock.""" with self._batch_lock: self._flush_batch_locked() - + def _flush_batch_locked(self) -> None: - """Flush current batch (called while holding lock).""" + """Flush current batch — must be called while holding _batch_lock.""" if not self._event_batch: return - + events_to_send = self._event_batch.copy() self._event_batch.clear() - + if self._flush_timer: self._flush_timer.cancel() self._flush_timer = None - - # Send in background thread - threading.Thread(target=self._send_events, args=(events_to_send,), daemon=True).start() - + + threading.Thread( + target=self._send_events, args=(events_to_send,), daemon=True + ).start() + def _send_events(self, events: List[Dict[str, Any]]) -> None: - """Send events using requests library.""" - if not events: + """POST a batch of events to the ingestor (runs in a daemon thread).""" + if not events or not self._client_key: return - + + url = f"{self.ingestor_host}/track?client_key={self._client_key}" + body = json.dumps(events) + try: - payload = { - 'events': events, - 'client_key': self._client_key - } - - url = f"{self.ingestor_host}/events" response = requests.post( url, - json=payload, - headers={'User-Agent': f'growthbook-python-sdk/{self._get_sdk_version()}'}, - timeout=30 + data=body, + headers={ + "Content-Type": "text/plain", + "Accept": "application/json", + "User-Agent": f"growthbook-python-sdk/{self._get_sdk_version()}", + }, + timeout=30, ) - if response.status_code == 200: - self.logger.debug(f"Successfully sent {len(events)} events") + self.logger.debug("Sent %d event(s) to ingestor", len(events)) else: - self.logger.warning(f"Ingestor returned status {response.status_code}") - + self.logger.warning( + "Ingestor returned HTTP %s for %d event(s)", + response.status_code, + len(events), + ) except Exception as e: - self.logger.error(f"Failed to send events: {e}") - + self.logger.error("Failed to send events: %s", e) + + # ------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------ + def _get_sdk_version(self) -> str: - """Get SDK version.""" try: import growthbook - return getattr(growthbook, '__version__', 'unknown') - except: - return 'unknown' + + return getattr(growthbook, "__version__", "unknown") + except Exception: + return "unknown" def growthbook_tracking_plugin(**options) -> GrowthBookTrackingPlugin: - """Create a GrowthBook tracking plugin.""" - return GrowthBookTrackingPlugin(**options) \ No newline at end of file + """Factory helper — returns a configured :class:`GrowthBookTrackingPlugin`.""" + return GrowthBookTrackingPlugin(**options) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 36f69ba..901d475 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -6,6 +6,7 @@ - GrowthBookTrackingPlugin functionality """ +import json import unittest from unittest.mock import patch, MagicMock from growthbook import ( @@ -425,4 +426,232 @@ def track_callback(experiment, result, user_context): self.assertEqual(legacy_events[0]['client_type'], 'legacy') # Clean up request context - clear_request_context() \ No newline at end of file + clear_request_context() + + +class TestLogEvent(unittest.TestCase): + """Tests for gb.log_event() and GrowthBookClient.log_event().""" + + # ------------------------------------------------------------------ + # Sync GrowthBook + # ------------------------------------------------------------------ + + def test_log_event_warns_without_plugin(self): + """log_event should warn and do nothing when no event logger is set.""" + gb = GrowthBook(attributes={"id": "u1"}) + with self.assertLogs("growthbook", level="WARNING") as cm: + gb.log_event("page_view", {"path": "/home"}) + self.assertTrue(any("no event logger" in msg.lower() for msg in cm.output)) + gb.destroy() + + def test_set_event_logger_called_on_log_event(self): + """set_event_logger registers a callable invoked by log_event.""" + received = [] + + def my_logger(event_name, properties, user_context): + received.append((event_name, properties, user_context)) + + gb = GrowthBook(attributes={"id": "u1"}) + gb.set_event_logger(my_logger) + gb.log_event("button_clicked", {"button": "cta"}) + + self.assertEqual(len(received), 1) + self.assertEqual(received[0][0], "button_clicked") + self.assertEqual(received[0][1], {"button": "cta"}) + gb.destroy() + + def test_log_event_with_plugin_sends_correct_payload(self): + """Plugin wires the event logger; log_event triggers an ingestor POST.""" + posted = [] + + def fake_post(url, data, headers, timeout): + posted.append({"url": url, "body": json.loads(data), "headers": headers}) + response = MagicMock() + response.status_code = 200 + return response + + with patch("requests.post", side_effect=fake_post): + gb = GrowthBook( + attributes={"id": "user-42", "user_id": "u42"}, + client_key="sdk-test-key", + plugins=[ + growthbook_tracking_plugin( + ingestor_host="https://us1.gb-ingest.com", + batch_size=1, # flush immediately + ) + ], + ) + gb.log_event("checkout_started", {"cart_value": 99}) + # Give the daemon thread a moment to post + import time; time.sleep(0.05) + + self.assertEqual(len(posted), 1, "Expected exactly one POST") + url = posted[0]["url"] + self.assertIn("/track", url) + self.assertIn("client_key=sdk-test-key", url) + + body = posted[0]["body"] + self.assertIsInstance(body, list) + self.assertEqual(len(body), 1) + + event = body[0] + self.assertEqual(event["event_name"], "checkout_started") + self.assertEqual(event["properties_json"], {"cart_value": 99}) + self.assertEqual(event["sdk_language"], "python") + self.assertEqual(event["user_id"], "u42") + self.assertEqual(event["device_id"], "user-42") # falls back to "id" + + # UTM and other top-level attrs should NOT appear in context_json + self.assertNotIn("user_id", event["context_json"]) + + self.assertEqual(posted[0]["headers"]["Content-Type"], "text/plain") + gb.destroy() + + def test_log_event_empty_properties(self): + """log_event works with no properties argument.""" + received = [] + + def my_logger(event_name, properties, user_context): + received.append(properties) + + gb = GrowthBook(attributes={"id": "u1"}) + gb.set_event_logger(my_logger) + gb.log_event("page_view") + + self.assertEqual(received[0], {}) + gb.destroy() + + def test_event_logger_cleared_on_destroy(self): + """Event logger reference is cleared when GrowthBook is destroyed.""" + gb = GrowthBook(attributes={"id": "u1"}) + gb.set_event_logger(lambda *a: None) + self.assertIsNotNone(gb._event_logger) + gb.destroy() + self.assertIsNone(gb._event_logger) + + def test_log_event_uses_current_attributes_after_set_attributes(self): + """log_event must reflect the latest attributes, not the ones from __init__. + + set_attributes() replaces self._attributes but _user_ctx.attributes still + points to the old dict until _get_eval_context() syncs it. log_event must + perform the same sync so callers always see current state. + """ + received = [] + + def my_logger(event_name, properties, user_context): + received.append(dict(user_context.attributes)) + + gb = GrowthBook(attributes={"id": "old-user"}) + gb.set_event_logger(my_logger) + + gb.set_attributes({"id": "new-user"}) + gb.log_event("page_view") + + self.assertEqual(len(received), 1) + self.assertEqual(received[0]["id"], "new-user") + + # ------------------------------------------------------------------ + # Async GrowthBookClient + # ------------------------------------------------------------------ + + def test_async_client_log_event_warns_without_plugin(self): + """GrowthBookClient.log_event warns when no event logger is configured.""" + import asyncio + from growthbook.growthbook_client import GrowthBookClient + from growthbook.common_types import Options + + client = GrowthBookClient(Options(client_key="sdk-key")) + + async def run(): + with self.assertLogs("growthbook.growthbook_client", level="WARNING") as cm: + await client.log_event("test_event") + self.assertTrue(any("no event logger" in msg.lower() for msg in cm.output)) + + asyncio.run(run()) + + def test_async_client_set_event_logger_and_log_event(self): + """GrowthBookClient.set_event_logger and log_event work together.""" + import asyncio + from growthbook.growthbook_client import GrowthBookClient + from growthbook.common_types import Options, UserContext + + received = [] + + def sync_logger(event_name, properties, user_context): + received.append((event_name, properties, user_context.attributes)) + + client = GrowthBookClient(Options(client_key="sdk-key")) + client.set_event_logger(sync_logger) + + async def run(): + ctx = UserContext(attributes={"id": "async-user"}) + await client.log_event("form_submitted", {"form": "signup"}, ctx) + + asyncio.run(run()) + + self.assertEqual(len(received), 1) + self.assertEqual(received[0][0], "form_submitted") + self.assertEqual(received[0][1], {"form": "signup"}) + self.assertEqual(received[0][2], {"id": "async-user"}) + + def test_async_client_plugin_wires_event_logger(self): + """Plugin initialised on GrowthBookClient registers an event logger.""" + from growthbook.growthbook_client import GrowthBookClient + from growthbook.common_types import Options + + client = GrowthBookClient( + Options( + client_key="sdk-key", + tracking_plugins=[ + growthbook_tracking_plugin(ingestor_host="https://us1.gb-ingest.com") + ], + ) + ) + # The plugin should have called set_event_logger + self.assertIsNotNone(client.options.event_logger) + + # ------------------------------------------------------------------ + # Payload format helpers + # ------------------------------------------------------------------ + + def test_build_event_payload_attribute_splitting(self): + """Known top-level attributes are promoted; others go into context_json.""" + from growthbook.plugins.growthbook_tracking import _build_event_payload + + attrs = { + "user_id": "u1", + "device_id": "d1", + "page_id": "p1", + "session_id": "s1", + "utmSource": "google", + "pageTitle": "Home", + "custom_attr": "keep", + } + payload = _build_event_payload( + event_name="test", + properties={"k": "v"}, + attributes=attrs, + url="https://example.com/", + sdk_version="2.x.x", + ) + + self.assertEqual(payload["user_id"], "u1") + self.assertEqual(payload["device_id"], "d1") + self.assertEqual(payload["page_id"], "p1") + self.assertEqual(payload["session_id"], "s1") + self.assertEqual(payload["utm_source"], "google") + self.assertEqual(payload["page_title"], "Home") + self.assertEqual(payload["context_json"], {"custom_attr": "keep"}) + # Top-level attrs must not leak into context_json + self.assertNotIn("user_id", payload["context_json"]) + self.assertNotIn("utmSource", payload["context_json"]) + + def test_build_event_payload_device_id_fallback(self): + """device_id falls back to anonymous_id then id.""" + from growthbook.plugins.growthbook_tracking import _build_event_payload + + payload = _build_event_payload("e", {}, {"anonymous_id": "anon-1"}, "", "x") + self.assertEqual(payload["device_id"], "anon-1") + + payload2 = _build_event_payload("e", {}, {"id": "raw-id"}, "", "x") + self.assertEqual(payload2["device_id"], "raw-id") \ No newline at end of file