Skip to content

Commit

Permalink
feat: Add Waffle config watcher (#481)
Browse files Browse the repository at this point in the history
Listens to Waffle model changes and reports them 1) to the log and 2) to
a Slack webhook.

This adds a dependency on django-waffle.
  • Loading branch information
timmc-edx authored Oct 27, 2023
1 parent d44fb66 commit 25887c7
Show file tree
Hide file tree
Showing 8 changed files with 153 additions and 2 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ Change Log
Unreleased
~~~~~~~~~~

[2.2.0] - 2023-10-27
~~~~~~~~~~~~~~~~~~~~

Added
_____

* Add ``edx_arch_experiments.config_watcher`` Django app for monitoring Waffle changes
* Add script to get github action errors
* Add script to republish failed events

Expand Down
2 changes: 1 addition & 1 deletion edx_arch_experiments/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
A plugin to include applications under development by the architecture team at 2U.
"""

__version__ = '2.1.0'
__version__ = '2.2.0'
Empty file.
17 changes: 17 additions & 0 deletions edx_arch_experiments/config_watcher/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
App for reporting configuration changes to Slack for operational awareness.
"""

from django.apps import AppConfig


class ConfigWatcherApp(AppConfig):
"""
Django application to report configuration changes to operators.
"""
name = 'edx_arch_experiments.config_watcher'

def ready(self):
from .signals import receivers # pylint: disable=import-outside-toplevel

receivers.connect_receivers()
Empty file.
123 changes: 123 additions & 0 deletions edx_arch_experiments/config_watcher/signals/receivers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""
Signal receivers for the config watcher.
Call ``connect_receivers`` to initialize.
"""

import html
import json
import logging
import urllib.request

import waffle.models
from django.conf import settings
from django.db.models import signals
from django.dispatch import receiver

log = logging.getLogger(__name__)

# .. setting_name: CONFIG_WATCHER_SLACK_WEBHOOK_URL
# .. setting_default: None
# .. setting_description: Slack webhook URL to send config change events to.
# If not configured, this functionality is disabled.
CONFIG_WATCHER_SLACK_WEBHOOK_URL = getattr(settings, 'CONFIG_WATCHER_SLACK_WEBHOOK_URL', None)


def _send_to_slack(message):
"""Send this message as plain text to the configured Slack channel."""
if not CONFIG_WATCHER_SLACK_WEBHOOK_URL:
return

# https://api.slack.com/reference/surfaces/formatting
body_data = {
'text': html.escape(message, quote=False)
}

req = urllib.request.Request(
url=CONFIG_WATCHER_SLACK_WEBHOOK_URL, data=json.dumps(body_data).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=2) as resp:
status = resp.getcode()
if status != 200:
log.error(f"Slack rejected the config watcher message. {status=}, body={resp.read().decode('utf-8')}")


def _report_config_change(message):
"""
Report this message string as a configuration change.
Sends to logs and to Slack.
"""
log.info(message)
_send_to_slack(message)


def _report_waffle_change(model_short_name, instance, created, fields):
"""
Report that a model instance has been created or updated.
"""
verb = "created" if created else "updated"
state_desc = ", ".join(f"{field}={repr(getattr(instance, field))}" for field in fields)
_report_config_change(f"Waffle {model_short_name} {instance.name!r} was {verb}. New config: {state_desc}")


def _report_waffle_delete(model_short_name, instance):
"""
Report that a model instance has been deleted.
"""
_report_config_change(f"Waffle {model_short_name} {instance.name!r} was deleted")


# List of models to observe. Each is a dictionary that matches the
# keyword args of _register_waffle_observation.
_WAFFLE_MODELS_TO_OBSERVE = [
{
'model': waffle.models.Flag,
'short_name': 'flag',
'fields': ['everyone', 'percent', 'superusers', 'staff', 'authenticated', 'note', 'languages'],
},
{
'model': waffle.models.Switch,
'short_name': 'switch',
'fields': ['active', 'note'],
},
{
'model': waffle.models.Sample,
'short_name': 'sample',
'fields': ['percent', 'note'],
},
]


def _register_waffle_observation(*, model, short_name, fields):
"""
Register a Waffle model for observation according to config values.
Args:
model (class): The model class to monitor
short_name (str): A short descriptive name for an instance of the model, e.g. "flag"
fields (list): Names of fields to report on in the Slack message
"""
@receiver(signals.post_save, sender=model)
def report_waffle_change(*args, instance, created, **kwargs):
try:
_report_waffle_change(short_name, instance, created, fields)
except: # noqa pylint: disable=bare-except
# Log and suppress error so Waffle change can proceed
log.exception(f"Failed to report change to waffle {short_name}")

@receiver(signals.post_delete, sender=model)
def report_waffle_delete(*args, instance, **kwargs):
try:
_report_waffle_delete(short_name, instance)
except: # noqa pylint: disable=bare-except
log.exception(f"Failed to report deletion of waffle {short_name}")


def connect_receivers():
"""
Initialize application's receivers.
"""
for config in _WAFFLE_MODELS_TO_OBSERVE:
_register_waffle_observation(**config)
1 change: 1 addition & 0 deletions requirements/base.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@

Django # Web application framework
edx_django_utils
django-waffle # Configuration switches and flags -- used by config_watcher app
4 changes: 3 additions & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ django==3.2.22
django-crum==0.7.9
# via edx-django-utils
django-waffle==4.0.0
# via edx-django-utils
# via
# -r requirements/base.in
# edx-django-utils
edx-django-utils==5.7.0
# via -r requirements/base.in
newrelic==9.1.0
Expand Down

0 comments on commit 25887c7

Please sign in to comment.