Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Posthog integration to backend #682

Merged
merged 16 commits into from
Mar 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ ACCESS_TOKEN_URL=
USERINFO_URL=
KEYCLOAK_BASE_URL=
KEYCLOAK_REALM_NAME=

POSTHOG_ENABLED=False
POSTHOG_PROJECT_API_KEY=
POSTHOG_HOST=https://app.posthog.com
12 changes: 12 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,18 @@
"KEYCLOAK_BASE_URL": {
"description": "The base URL for a Keycloak configuration.",
"required": true
},
"POSTHOG_ENABLED": {
"description": "Whether to enable Posthog feature flags",
"required": false
},
"POSTHOG_API_HOST": {
"description": "API host for PostHog",
"required": false
},
"POSTHOG_PROJECT_API_KEY": {
"description": "Private API key to communicate with PostHog",
"required": false
}
},
"keywords": ["Django", "Python", "MIT", "Office of Digital Learning"],
Expand Down
16 changes: 16 additions & 0 deletions main/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.apps import AppConfig


class MainConfig(AppConfig):
"""
Main configuration
"""

default_auto_field = "django.db.models.BigAutoField"
name = "main"

def ready(self):
"""Initialize the app"""
from main import features

features.configure()
176 changes: 161 additions & 15 deletions main/features.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,180 @@
"""MIT Open feature flags"""

import hashlib
import json
import logging
from enum import StrEnum
from functools import wraps
from typing import Optional

import posthog
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import caches
from django.core.exceptions import ObjectDoesNotExist

INDEX_UPDATES = "INDEX_UPDATES"
PROFILE_UI = "PROFILE_UI"
COURSE_UI = "COURSE_UI"
COURSE_FILE_SEARCH = "COURSE_FILE_SEARCH"
HOT_POST_REPAIR = "HOT_POST_REPAIR"
PODCAST_APIS = "PODCAST_APIS"
PODCAST_SEARCH = "PODCAST_SEARCH"
USER_LIST_SEARCH = "USER_LIST_SEARCH"
from authentication.backends.ol_open_id_connect import OlOpenIdConnectAuth

log = logging.getLogger()
User = get_user_model()
durable_cache = caches["durable"]

def is_enabled(name, default=None):

class Features(StrEnum):
"""Enum for feature flags"""


def configure():
"""
Configure the posthog default_client.

The posthog library normally takes care of this but it doesn't
expose all the client config options.
"""
posthog.default_client = posthog.Client(
api_key=settings.POSTHOG_PROJECT_API_KEY,
host=settings.POSTHOG_API_HOST,
debug=settings.DEBUG,
on_error=None,
send=None,
sync_mode=False,
poll_interval=30,
disable_geoip=True,
feature_flags_request_timeout_seconds=3,
)


def default_unique_id() -> str:
"""Get the default unique_id if it's not provided"""
return settings.HOSTNAME


def user_unique_id(user: Optional[User]) -> Optional[str]:
"""
Returns True if the feature flag is enabled
Get a unique id for a given user.
"""
if user is None or user.is_anonymous():
return None

try:
# we use the keycloak uid because that should be ubiquitous across all apps
return user.social_auth.get(provider=OlOpenIdConnectAuth.name).uid
except ObjectDoesNotExist:
# this user was created out-of-band (e.g. createsuperuser)
# so we won't support this edge case
log.exception(
"Unable to pick posthog unique_id for user due to no keycloak auth: %s",
user.id,
)
return None
except Exception:
log.exception("Unexpected error trying to pick posthog unique_id for user")
return None


def _get_person_properties(unique_id: str) -> dict:
"""
Get posthog person_properties based on unique_id
"""
return {
"environment": settings.ENVIRONMENT,
"user_id": unique_id,
}


def generate_cache_key(key: str, unique_id: str, person_properties: dict) -> str:
"""
Generate a cache key for the feature flag.

To prevent collisions, we take the unique_id and person_properties that get
passed to the feature flag functions below, combine them, and hash them.
Append the flag key to this to store the value in the cache.
"""

return "{}_{}".format(
str(
hashlib.sha256(
json.dumps((unique_id, person_properties)).encode("utf-8")
).hexdigest()
),
key,
)


def get_all_feature_flags(opt_unique_id: Optional[str] = None):
"""
Get the set of all feature flags
"""
unique_id = opt_unique_id or default_unique_id()
person_properties = _get_person_properties(unique_id)

flag_data = posthog.get_all_flags(
unique_id,
person_properties=person_properties,
)

[
durable_cache.set(generate_cache_key(k, unique_id, person_properties), v)
for k, v in flag_data.items()
]

return flag_data


def is_enabled(
name: str,
default: Optional[bool] = None,
opt_unique_id: Optional[str] = None,
) -> bool:
"""
Return True if the feature flag is enabled

Args:
name (str): feature flag name
default (bool): default value if not set in settings
unique_id (str):
person identifier passed back to posthog which is the display value for
person. I recommend this be user.id for logged-in users to allow for
more readable user flags as well as more clear troubleshooting. For
anonymous users, a persistent ID will help with troubleshooting and tracking
efforts.

Returns:
bool: True if the feature flag is enabled
""" # noqa: D401
return settings.FEATURES.get(name, default or settings.MITOPEN_FEATURES_DEFAULT)
"""
unique_id = opt_unique_id or default_unique_id()
person_properties = _get_person_properties(unique_id)

cache_key = generate_cache_key(name, unique_id, person_properties)
cached_value = durable_cache.get(cache_key)

if cached_value is not None:
log.debug("Retrieved %s from the cache", name)
return cached_value
else:
log.debug("Retrieving %s from Posthog", name)

# value will be None if either there is no value or we can't get a response back
value = (
posthog.get_feature_flag(
name,
unique_id,
person_properties=person_properties,
)
if settings.POSTHOG_ENABLED
else None
)

durable_cache.set(cache_key, value) if value is not None else None

return (
value
if value is not None
else settings.FEATURES.get(name, default or settings.MITOPEN_FEATURES_DEFAULT)
)


def if_feature_enabled(name, default=None):
def if_feature_enabled(name: str, default: Optional[bool] = None):
"""
Wrapper that results in a no-op if the given feature isn't enabled, and otherwise
runs the wrapped function as normal.
Expand All @@ -38,9 +184,9 @@ def if_feature_enabled(name, default=None):
default (bool): default value if not set in settings
""" # noqa: D401

def if_feature_enabled_inner(func): # pylint: disable=missing-docstring
def if_feature_enabled_inner(func):
@wraps(func)
def wrapped_func(*args, **kwargs): # pylint: disable=missing-docstring
def wrapped_func(*args, **kwargs):
if not is_enabled(name, default):
# If the given feature name is not enabled, do nothing (no-op).
return None
Expand Down
116 changes: 116 additions & 0 deletions main/features_test.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
"""Tests for feature flags"""

import logging
from datetime import timedelta

import pytest
from django.core.cache import caches
from freezegun import freeze_time

from main import features
from main.utils import now_in_utc

pytestmark = [pytest.mark.django_db]


@pytest.mark.parametrize(
Expand Down Expand Up @@ -67,3 +75,111 @@ def mock_editing_func(value): # pylint: disable=missing-docstring

mock_editing_func(update_value)
assert some_mock.value == expected_result_value


"""
Tests for Posthog and caching functionality

- Test grabbing flags from Posthog with a cleared cache; they should hit
Posthog and then the flag should be cached
- Test population of the cache with calls to get_all
- Test flag grabbing after timeout
"""


def test_flags_from_cache(mocker, caplog, settings):
"""Test that flags are pulled from cache successfully."""
get_feature_flag_mock = mocker.patch(
"posthog.get_feature_flag", autospec=True, return_value=True
)
durable_cache = caches["durable"]
settings.FEATURES["testing_function"] = True
settings.POSTHOG_ENABLED = True
cache_key = features.generate_cache_key(
"testing_function",
features.default_unique_id(),
features._get_person_properties(features.default_unique_id()), # noqa: SLF001
)
durable_cache.clear()

# Cache cleared, so we should hit Posthog.

with caplog.at_level(logging.DEBUG):
was_enabled = features.is_enabled("testing_function")

assert was_enabled
assert durable_cache.get(cache_key, None) is not None
get_feature_flag_mock.assert_called()

assert "from Posthog" in caplog.text

# Cache has stuff, so we should get it from that now.

get_feature_flag_mock.reset_mock()

with caplog.at_level(logging.DEBUG):
was_enabled = features.is_enabled("testing_function")

assert was_enabled
assert durable_cache.get(cache_key, None) is not None
get_feature_flag_mock.assert_not_called()

assert "from the cache" in caplog.text


def test_cache_population(mocker, settings):
"""Test that the cache is populated correctly when get_all_feature_flags is called."""

get_feature_flag_mock = mocker.patch(
"posthog.get_feature_flag", autospec=True, return_value=True
)
get_all_flags_mock = mocker.patch(
"posthog.get_all_flags",
autospec=True,
return_value={
"testing_function_1": True,
"testing_function_2": True,
"testing_function_3": True,
},
)

durable_cache = caches["durable"]

settings.FEATURES["testing_function_1"] = True
settings.FEATURES["testing_function_2"] = True
settings.FEATURES["testing_function_3"] = True
settings.POSTHOG_ENABLED = True

durable_cache.clear()

all_flags = features.get_all_feature_flags()

get_all_flags_mock.assert_called()

for k in all_flags:
assert features.is_enabled(k)
get_feature_flag_mock.assert_not_called()


def test_posthog_flag_cache_timeout(mocker, settings):
"""Test that the cache gets invalidated as we expect"""

get_feature_flag_mock = mocker.patch(
"posthog.get_feature_flag", autospec=True, return_value=True
)
durable_cache = caches["durable"]
settings.POSTHOG_ENABLED = True

durable_cache.clear()

timeout = settings.CACHES["durable"].get("TIMEOUT", 300)

time_freezer = freeze_time(now_in_utc() + timedelta(seconds=timeout * 2))

assert features.is_enabled("test_function")
get_feature_flag_mock.assert_called()

time_freezer.start()
assert features.is_enabled("test_function")
get_feature_flag_mock.assert_called()
time_freezer.stop()
10 changes: 0 additions & 10 deletions main/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

from rest_framework import permissions

from main import features


def is_admin_user(request):
"""
Expand Down Expand Up @@ -120,11 +118,3 @@ class ObjectOnlyPermissions(permissions.DjangoObjectPermissions):
def has_permission(self, request, view): # noqa: ARG002
"""Ignores model-level permissions"""
return True


class PodcastFeatureFlag(permissions.BasePermission):
"""Forbids access if the podcast feature flag is not enabled"""

def has_permission(self, request, view): # noqa: ARG002
"""Check that the feature flag is enabled"""
return features.is_enabled(features.PODCAST_APIS)
Loading
Loading