Repository: openfun/richie

Issue:
Add & configure raven.js
## Purpose

As mentioned in #112, we need to integrate and configure [Raven.js](https://github.com/getsentry/raven-js) to also catch front-end application errors [1, 2].

## Proposal

- [ ] load the `raven` template tag library in the base Django template
- [ ] define a `sentry_public_dsn` JavaScript variable in a `script` block from the Django base template (using `{% sentry_public_dsn %}`)
- [ ] add `raven.js` to front-end dependencies and build
- [ ] configure the client via `Raven.config(sentry_public_dsn).install()`
- [ ] wrap front-end app using `Raven.context`

## References

[1] https://docs.sentry.io/clients/python/integrations/django/#using-with-raven-js
[2] https://docs.sentry.io/clients/javascript/


Discussion:
For client side-errors, it would seem to me that it's cleaner to just `yarn add` Raven.js and then [import it as an ES6 module](https://docs.sentry.io/clients/javascript/install/#es2015-es6) for use in our code. 

Not sure what Django template integration brings us. At most I see integrating the DSN as a global variable that the client will pick up (if we don't want to manage it in the client settings.json directly).

Something like `<script>window.SENTRY_PUBLIC_DSN = {% sentry_public_dsn %}</script>` inside `<head>` that we make sure is above any JS code includes.
I do agree that it's way more clean to load raven as an ES6 module :heart:

But, as we have an hybrid approach concerning the front-end, we may miss application failures for everything related to DjangoCMS' or any third-party JavaScript app loaded by the CMS.

Hence, we need to define a specific block in our base Django template to load & configure Raven.js. By doing so, we will be able to override this behavior when only our front-end app is loaded.

WDYT?
I overlooked the third-party JS part. I think on the front-office there would probably be no other JS than ours involved but on the back-office it's a different story and we need to catch errors there too.

Therefore, it's probably better to just reuse the global-scope Raven.js in our front code than to load it twice. (Maybe adding some logic to load it if it cannot be detected in the global scope?)
> Maybe adding some logic to load it if it cannot be detected in the global scope?

Yup. Good idea!
Just a quick note to point out that is is probably affected by #72 and generally by the way we want to split our apps into separate reusable packages.

It's probably a good idea to wait until the split is effective to work on sentry/whatever reporting.

Resolving patch:
diff --git a/sandbox/settings.py b/sandbox/settings.py
--- a/sandbox/settings.py
+++ b/sandbox/settings.py
@@ -18,7 +18,7 @@
 
 
 def get_release():
-    """Get current release of the application
+    """Get the current release of the application.
 
     By release, we mean the release from the version.json file à la Mozilla [1]
     (if any). If this file has not been found, it defaults to "NA".
@@ -26,17 +26,13 @@ def get_release():
     [1]
     https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
     """
-
     # Try to get the current release from the version.json file generated by the
     # CI during the Docker image build
     try:
         with open(os.path.join(BASE_DIR, "version.json")) as version:
             return json.load(version)["version"]
     except FileNotFoundError:
-        pass
-
-    # Default: not available
-    return "NA"
+        return "NA"  # Default: not available
 
 
 class DRFMixin:
@@ -145,6 +141,7 @@ class Base(DRFMixin, RichieCoursesConfigurationMixin, Configuration):
                     "django.template.context_processors.static",
                     "cms.context_processors.cms_settings",
                     "richie.apps.core.context_processors.site_metas",
+                    "richie.apps.core.context_processors.frontend_context",
                 ],
                 "loaders": [
                     "django.template.loaders.filesystem.Loader",
@@ -300,6 +297,25 @@ class Base(DRFMixin, RichieCoursesConfigurationMixin, Configuration):
         "elasticsearch", environ_name="RICHIE_ES_HOST", environ_prefix=None
     )
 
+    # Sentry
+    SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
+
+    # pylint: disable=invalid-name
+    @property
+    def ENVIRONMENT(self):
+        """Environment in which the application is launched."""
+        return self.__class__.__name__.lower()
+
+    # pylint: disable=invalid-name
+    @property
+    def RELEASE(self):
+        """
+        Return the release information.
+
+        Delegate to the module function to enable easier testing.
+        """
+        return get_release()
+
     @classmethod
     def post_setup(cls):
         """Post setup configuration.
@@ -308,16 +324,16 @@ def post_setup(cls):
         """
         super().post_setup()
 
-        # The DJANGO_SENTRY_DSN environment variable should be set to activate
-        # sentry for an environment
-        sentry_dsn = values.Value(None, environ_name="SENTRY_DSN")
-        if sentry_dsn is not None:
+        # The SENTRY_DSN setting should be available to activate sentry for an environment
+        if cls.SENTRY_DSN is not None:
             sentry_sdk.init(
-                dsn=sentry_dsn,
-                environment=cls.__name__.lower(),
-                release=get_release(),
+                dsn=cls.SENTRY_DSN,
+                environment=cls.ENVIRONMENT,
+                release=cls.RELEASE,
                 integrations=[DjangoIntegration()],
             )
+            with sentry_sdk.configure_scope() as scope:
+                scope.set_extra("application", "backend")
 
 
 class Development(Base):
diff --git a/src/richie/apps/core/context_processors.py b/src/richie/apps/core/context_processors.py
--- a/src/richie/apps/core/context_processors.py
+++ b/src/richie/apps/core/context_processors.py
@@ -1,8 +1,11 @@
 """
 Template context processors
 """
+import json
+
 from django.conf import settings
 from django.contrib.sites.models import Site
+from django.templatetags.static import static
 
 
 def get_site_metas(with_static=False, with_media=False, is_secure=False, extra=None):
@@ -57,3 +60,21 @@ def site_metas(request):
     Context processor to add the current *Site* metas to the context
     """
     return get_site_metas(is_secure=request.is_secure())
+
+
+def frontend_context(request):
+    """
+    Context processor to add the context as expected by the frontend to the template context.
+    """
+    return {
+        "FRONTEND_CONTEXT": json.dumps(
+            {
+                "context": {
+                    "assets": {"icons": static("richie/icons.svg")},
+                    "environment": getattr(settings, "ENVIRONMENT", ""),
+                    "release": getattr(settings, "RELEASE", ""),
+                    "sentry_dsn": getattr(settings, "SENTRY_DSN", ""),
+                }
+            }
+        )
+    }


Test patch:
diff --git a/tests/apps/core/test_pages.py b/tests/apps/core/test_pages.py
--- a/tests/apps/core/test_pages.py
+++ b/tests/apps/core/test_pages.py
@@ -1,9 +1,12 @@
 """
 Test suite for all utils in the `core` application
 """
+from django.test import override_settings
+
 from cms.api import Page
 from cms.test_utils.testcases import CMSTestCase
 
+from richie.apps.core.factories import PageFactory
 from richie.apps.core.helpers import create_i18n_page
 
 
@@ -33,3 +36,17 @@ def test_pages_i18n(self):
         self.assertEqual(200, response.status_code)
         # ... and make sure the page menu is present in english on the page
         self.assertIn(content["en"], response.rendered_content)
+
+    @override_settings(SENTRY_DSN="https://example.com/sentry/dsn")
+    @override_settings(RELEASE="9.8.7")
+    @override_settings(ENVIRONMENT="test_pages")
+    def test_page_includes_frontend_context(self):
+        """
+        Create a page and make sure it includes the frontend context as included
+        in `base.html`.
+        """
+        page = PageFactory(should_publish=True, template="richie/single_column.html")
+        response = self.client.get(page.get_public_url())
+        self.assertContains(response, '"environment": "test_pages"')
+        self.assertContains(response, '"release": "9.8.7"')
+        self.assertContains(response, '"sentry_dsn": "https://example.com/sentry/dsn"')