Tabeer.ai already had PostHog wired into the frontend for analytics, gated behind cookie consent the way GDPR-conscious analytics should be. That's the right call for tracking user behavior — but it created a real question when a new backend feature (a question-search endpoint for content staff) needed a gradual rollout: should that also wait on consent?
Flags Aren't Analytics
The answer is no, and the reasoning is worth being explicit about. A feature flag controlling whether an authenticated staff member's request hits a new code path isn't tracking anything about that person's behavior for measurement purposes — it's an operational switch, the same category as an environment variable, just one that can be flipped without a deploy. Gating that behind cookie consent would be conflating two different concerns: "can we measure what this user does" and "should this server-side code branch run." The architecture split that came out of that reasoning:
- Operational rollout flags → server-side, via PostHog's Python SDK, no consent-gating, because they control app behavior, not tracking.
- A/B test experiments → client-side, through the already consent-gated PostHog JS SDK, because experiments inherently need analytics to measure outcomes — there's no version of an A/B test that doesn't require tracking which variant a user saw and what they did next.
The Helper
A thin wrapper around the posthog Python package, not a direct dependency scattered through
every view that needs a flag check:
from django.conf import settings
_client = None
def _get_client():
global _client
if _client is None and settings.POSTHOG_API_KEY:
import posthog as posthog_module
posthog_module.api_key = settings.POSTHOG_API_KEY
posthog_module.host = settings.POSTHOG_HOST
_client = posthog_module
return _client
def is_enabled(flag_key: str, user, default: bool = False) -> bool:
client = _get_client()
if client is None or user is None or not getattr(user, "is_authenticated", False):
return default
try:
result = client.feature_enabled(flag_key, str(user.pk))
except Exception:
return default
return bool(result) if result is not None else default
Three deliberate decisions baked into those few lines:
- Lazy client init — no PostHog connection attempt happens at all if
POSTHOG_API_KEYisn't set, so local development and CI never need a real API key just to import this module. str(user.pk)as the distinct ID — the same identifier PostHog would use for that user if they were also tracked client-side, so a flag or experiment defined once in the PostHog dashboard evaluates consistently whether it's checked from Django or from the browser.- Fails open to
default, not closed — any exception from the PostHog call (network blip, API outage, rate limit) returns the caller's specified default rather than propagating. A feature flag SDK going down should never be able to 500 an otherwise-working endpoint.
Using It
def get_queryset(self):
if not feature_flags.is_enabled("staff-question-search", self.request.user, default=False):
return Question.objects.none()
...
default=False here is intentional and matches "fail closed on absence, fail open on error" —
if PostHog has no opinion (flag doesn't exist, or the API call itself errors), the safer default
for a not-yet-fully-tested feature is off, not on. A different flag guarding, say, a
performance optimization with no correctness risk might reasonably default to True instead —
the helper doesn't hardcode a philosophy, it takes the default as an explicit argument per call
site.
Tested Without a Real PostHog Connection
def test_calls_feature_enabled_with_user_pk_as_distinct_id(self):
mock_module = MagicMock()
mock_module.feature_enabled.return_value = True
with patch.dict("sys.modules", {"posthog": mock_module}):
with override_settings(POSTHOG_API_KEY="test-key"):
result = feature_flags.is_enabled("some-flag", self.user)
mock_module.feature_enabled.assert_called_once_with("some-flag", str(self.user.pk))
assert result is True
Four tests total: no API key configured → returns the default; anonymous or None user → returns
the default without ever calling PostHog; a real call passes the correct distinct ID; an exception
from PostHog fails open to the default. None of them touch the network — the point is verifying
the helper's logic, which doesn't require a live PostHog account to test correctly.
Why This Split Was Worth Getting Right
Getting flags-vs-experiments backwards would have meant either forcing an operational rollout switch to depend on a user having accepted a cookie banner (breaking the feature for anyone who declined analytics, for no actual privacy benefit), or running an A/B test without the tracking needed to ever read its results. Neither is a subtle mistake to unwind later — worth deciding deliberately up front instead.
If you're rolling out a backend feature gradually and want it decoupled from your analytics consent flow, this pattern generalizes past PostHog specifically. Get in touch.
By Shahid Malik