Skip to content
9 changes: 7 additions & 2 deletions mixpanel/flags/local_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
VariantSource,
)
from .utils import (
EXPOSURE_EVENT,
REQUEST_HEADERS,
dispatch_exposure,
generate_traceparent,
normalized_hash,
prepare_common_query_params,
Expand Down Expand Up @@ -534,12 +534,17 @@ def _track_exposure(
if latency_in_seconds is not None:
properties["Variant fetch latency (ms)"] = latency_in_seconds * 1000

self._tracker(distinct_id, EXPOSURE_EVENT, properties)
self._dispatch_exposure(distinct_id, properties)
else:
logger.error(
"Cannot track exposure event without a distinct_id in the context"
)

def _dispatch_exposure(self, distinct_id: str, properties: dict[str, Any]) -> None:
dispatch_exposure(
self._tracker, self._config.exposure_executor, distinct_id, properties
)

async def __aenter__(self):
return self

Expand Down
10 changes: 8 additions & 2 deletions mixpanel/flags/remote_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .utils import (
EXPOSURE_EVENT,
REQUEST_HEADERS,
dispatch_exposure,
generate_traceparent,
prepare_common_query_params,
)
Expand Down Expand Up @@ -279,7 +280,7 @@ def get_variant(
properties = self._build_tracking_properties(
flag_key, selected_variant, start_time, end_time
)
self._tracker(distinct_id, EXPOSURE_EVENT, properties)
self._dispatch_exposure(distinct_id, properties)

except Exception as exc:
logger.exception("Failed to get remote variant for flag '%s'", flag_key)
Expand Down Expand Up @@ -313,12 +314,17 @@ def track_exposure_event(
"""
if distinct_id := context.get("distinct_id"):
properties = self._build_tracking_properties(flag_key, variant)
self._tracker(distinct_id, EXPOSURE_EVENT, properties)
self._dispatch_exposure(distinct_id, properties)
else:
logger.error(
"Cannot track exposure event without a distinct_id in the context"
)

def _dispatch_exposure(self, distinct_id: str, properties: dict[str, Any]) -> None:
dispatch_exposure(
self._tracker, self._config.exposure_executor, distinct_id, properties
)

def _prepare_query_params(
self, context: dict[str, Any], flag_key: str | None = None
) -> dict[str, str]:
Expand Down
78 changes: 78 additions & 0 deletions mixpanel/flags/test_local_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
from itertools import chain, repeat
from typing import Any
from unittest.mock import Mock, patch
Expand Down Expand Up @@ -649,6 +650,83 @@ async def test_get_variant_value_does_not_track_exposure_without_distinct_id(sel
)
self._mock_tracker.assert_not_called()

@respx.mock
async def test_default_exposure_runs_inline_on_calling_thread(self):
"""Smoke test: exposure_executor defaults to None, tracker runs inline."""
flag = create_test_flag(rollout_percentage=100.0)
await self.setup_flags([flag])

called_on: list[threading.Thread] = []

def tracker(_distinct_id, _event, _properties):
called_on.append(threading.current_thread())

self._mock_tracker.side_effect = tracker
self._flags.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT)
assert called_on == [threading.current_thread()]

@respx.mock
async def test_track_exposure_event_routes_through_executor(self):
"""Manual API also honors exposure_executor."""
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="exposure")
try:
tracker_done = threading.Event()
captured: list[threading.Thread] = []

def tracker(_distinct_id, _event, _properties):
captured.append(threading.current_thread())
tracker_done.set()

config = LocalFlagsConfig(enable_polling=False, exposure_executor=executor)
provider = LocalFeatureFlagsProvider(
"test-token", config, "1.0.0", Mock(side_effect=tracker)
)
try:
provider.track_exposure_event(
"manual",
SelectedVariant(variant_key="treatment", variant_value="x"),
USER_CONTEXT,
)
assert tracker_done.wait(timeout=2.0)
assert captured[0] is not threading.current_thread()
assert captured[0].name.startswith("exposure")
finally:
await provider.__aexit__(None, None, None)
finally:
executor.shutdown(wait=True)

@respx.mock
async def test_exposure_executor_dispatches_tracker_off_calling_thread(self):
flag = create_test_flag(rollout_percentage=100.0)
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="exposure")
try:
calling_thread = threading.current_thread()
tracker_thread = threading.Event()
captured_thread: list[threading.Thread] = []

def tracker(_distinct_id, _event, _properties):
captured_thread.append(threading.current_thread())
tracker_thread.set()

tracker_mock = Mock(side_effect=tracker)
config = LocalFlagsConfig(enable_polling=False, exposure_executor=executor)
provider = LocalFeatureFlagsProvider(
"test-token", config, "1.0.0", tracker_mock
)
try:
respx.get("https://api.mixpanel.com/flags/definitions").mock(
return_value=create_flags_response([flag])
)
await provider.astart_polling_for_definitions()
provider.get_variant_value(TEST_FLAG_KEY, "fallback", USER_CONTEXT)
assert tracker_thread.wait(timeout=2.0), "tracker never ran"
assert captured_thread[0] is not calling_thread
assert captured_thread[0].name.startswith("exposure")
finally:
await provider.__aexit__(None, None, None)
finally:
executor.shutdown(wait=True)

@respx.mock
async def test_get_all_variants_returns_all_variants_when_user_in_rollout(self):
flag1 = create_test_flag(flag_key="flag1", rollout_percentage=100.0)
Expand Down
92 changes: 92 additions & 0 deletions mixpanel/flags/test_remote_feature_flags.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import Mock

import httpx
Expand Down Expand Up @@ -375,6 +377,96 @@ def test_get_variant_value_does_not_track_exposure_event_if_fallback(self):
)
self.mock_tracker.assert_not_called()

def test_default_exposure_runs_inline_on_calling_thread(self):
"""Smoke test: exposure_executor defaults to None, tracker runs inline."""
called_on: list[threading.Thread] = []

def tracker(_distinct_id, _event, _properties):
called_on.append(threading.current_thread())

provider = RemoteFeatureFlagsProvider(
"test-token", RemoteFlagsConfig(), "1.0.0", tracker
)
try:
provider.track_exposure_event(
"manual",
SelectedVariant(variant_key="treatment", variant_value="x"),
{"distinct_id": "user123"},
)
assert called_on == [threading.current_thread()]
finally:
provider.__exit__(None, None, None)

def test_track_exposure_event_routes_through_executor(self):
"""Manual API also honors exposure_executor."""
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="exposure")
try:
tracker_done = threading.Event()
captured: list[threading.Thread] = []

def tracker(_distinct_id, _event, _properties):
captured.append(threading.current_thread())
tracker_done.set()

provider = RemoteFeatureFlagsProvider(
"test-token",
RemoteFlagsConfig(exposure_executor=executor),
"1.0.0",
Mock(side_effect=tracker),
)
try:
provider.track_exposure_event(
"manual",
SelectedVariant(variant_key="treatment", variant_value="x"),
{"distinct_id": "user123"},
)
assert tracker_done.wait(timeout=2.0)
assert captured[0] is not threading.current_thread()
assert captured[0].name.startswith("exposure")
finally:
provider.__exit__(None, None, None)
finally:
executor.shutdown(wait=True)

@respx.mock
def test_exposure_executor_dispatches_tracker_off_calling_thread(self):
respx.get(ENDPOINT).mock(
return_value=create_success_response(
{
"test_flag": SelectedVariant(
variant_key="treatment", variant_value="treatment"
)
}
)
)

executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="exposure")
try:
calling_thread = threading.current_thread()
tracker_thread = threading.Event()
captured_thread: list[threading.Thread] = []

def tracker(_distinct_id, _event, _properties):
captured_thread.append(threading.current_thread())
tracker_thread.set()

tracker_mock = Mock(side_effect=tracker)
config = RemoteFlagsConfig(exposure_executor=executor)
provider = RemoteFeatureFlagsProvider(
"test-token", config, "1.0.0", tracker_mock
)
try:
provider.get_variant_value(
"test_flag", "control", {"distinct_id": "user123"}
)
assert tracker_thread.wait(timeout=2.0), "tracker never ran"
assert captured_thread[0] is not calling_thread
assert captured_thread[0].name.startswith("exposure")
finally:
provider.__exit__(None, None, None)
finally:
executor.shutdown(wait=True)

@respx.mock
def test_is_enabled_returns_true_for_true_variant_value(self):
respx.get(ENDPOINT).mock(
Expand Down
52 changes: 51 additions & 1 deletion mixpanel/flags/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
from __future__ import annotations

import logging
import re
from concurrent.futures import Future, ThreadPoolExecutor
from unittest.mock import MagicMock

import pytest

from .utils import generate_traceparent, normalized_hash
from .utils import (
_log_tracker_future_exception,
dispatch_exposure,
generate_traceparent,
normalized_hash,
)


class TestUtils:
Expand All @@ -31,3 +39,45 @@ def test_normalized_hash_for_known_inputs(self, key, salt, expected_hash):
assert result == expected_hash, (
f"Expected hash of {expected_hash} for '{key}' with salt '{salt}', got {result}"
)

def test_dispatch_exposure_runs_inline_when_no_executor(self):
tracker = MagicMock()

dispatch_exposure(tracker, None, "user-1", {"prop": "value"})

tracker.assert_called_once_with(
"user-1", "$experiment_started", {"prop": "value"}
)

def test_dispatch_exposure_logs_executor_thread_exceptions(self, caplog):
# Without the done-callback the future.exception() would be silently
# discarded — this test would fail (no log record captured).
def boom(*_args, **_kwargs):
raise RuntimeError("tracker exploded")

with ThreadPoolExecutor(max_workers=1) as executor:
with caplog.at_level(logging.ERROR, logger="mixpanel.flags.utils"):
dispatch_exposure(boom, executor, "user-1", {})
# Drain the executor so the done-callback has a chance to fire.
executor.shutdown(wait=True)

assert any(
"Exposure event failed on executor thread" in rec.message
and "tracker exploded" in rec.message
for rec in caplog.records
), f"expected error log, got {[r.message for r in caplog.records]}"

def test_log_tracker_future_exception_ignores_cancelled_future(self, caplog):
# future.exception() on a cancelled future raises CancelledError
# (a BaseException, not Exception) — without the guard, that
# would escape Future._invoke_callbacks and propagate into e.g.
# executor.shutdown(cancel_futures=True).
future: Future = Future()
assert future.cancel(), "fresh Future should be cancellable"

with caplog.at_level(logging.ERROR, logger="mixpanel.flags.utils"):
_log_tracker_future_exception(future) # must not raise

assert not any(
"Exposure event failed" in rec.message for rec in caplog.records
), "cancelled futures must not produce an error log"
5 changes: 5 additions & 0 deletions mixpanel/flags/types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from concurrent.futures import Executor
from typing import Any, Literal, Optional

from pydantic import BaseModel, ConfigDict
Expand All @@ -10,6 +11,10 @@ class FlagsConfig(BaseModel):

api_host: str = "api.mixpanel.com"
request_timeout_in_seconds: int = 10
# Optional executor used to dispatch exposure-event HTTP sends so flag
# evaluation does not block on the network round trip. None (default)
# preserves the existing inline behavior.
exposure_executor: Optional[Executor] = None


class LocalFlagsConfig(FlagsConfig):
Expand Down
Loading
Loading