Skip to content

Commit

Permalink
feat: add middleware to insert frontend moniroting script to response (
Browse files Browse the repository at this point in the history
…#415)

* feat: add middleware to insert frontend moniroting script to response

* fix: insert tag even when body tag is missing

* docs: update monitoring readme

* chore: bump version for release

* docs: fix format in readme

* fix: fix quality failures

* fix: update comment in code

Co-authored-by: Tim McCormack <[email protected]>

---------

Co-authored-by: Tim McCormack <[email protected]>
  • Loading branch information
iamsobanjaved and timmc-edx authored May 22, 2024
1 parent a5ffc86 commit bdc00e3
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 1 deletion.
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ Change Log

.. There should always be an "Unreleased" section for changes pending release.
[5.14.0] - 2024-05-22
---------------------
Added
~~~~~
* Added middleware named ``FrontendMonitoringMiddleware`` for inserting frontend monitoring HTML script tags to response, configured by new Django setting ``OPENEDX_TELEMETRY_FRONTEND_SCRIPTS``.


[5.13.0] - 2024-04-30
---------------------
Added
Expand Down
2 changes: 1 addition & 1 deletion edx_django_utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
EdX utilities for Django Application development..
"""

__version__ = "5.13.0"
__version__ = "5.14.0"

default_app_config = (
"edx_django_utils.apps.EdxDjangoUtilsConfig"
Expand Down
6 changes: 6 additions & 0 deletions edx_django_utils/monitoring/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Here is how you add the middleware:
'edx_django_utils.monitoring.CookieMonitoringMiddleware',
'edx_django_utils.monitoring.CodeOwnerMonitoringMiddleware',
'edx_django_utils.monitoring.CachedCustomMonitoringMiddleware',
'edx_django_utils.monitoring.FrontendMonitoringMiddleware',
'edx_django_utils.monitoring.MonitoringMemoryMiddleware',
)
Expand All @@ -103,6 +104,11 @@ Deployment Monitoring Middleware

Simply add ``DeploymentMonitoringMiddleware`` to monitor the python and django version of each request. See docstring for details.

Frontend Monitoring Middleware
--------------------------------

This middleware ``FrontendMonitoringMiddleware`` inserts frontend monitoring related HTML script tags to the response, see docstring for details.

Monitoring Memory Usage
-----------------------

Expand Down
1 change: 1 addition & 0 deletions edx_django_utils/monitoring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
CachedCustomMonitoringMiddleware,
CookieMonitoringMiddleware,
DeploymentMonitoringMiddleware,
FrontendMonitoringMiddleware,
MonitoringMemoryMiddleware
)
from .internal.transactions import (
Expand Down
59 changes: 59 additions & 0 deletions edx_django_utils/monitoring/internal/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import math
import platform
import random
import re
import warnings
from uuid import uuid4

Expand All @@ -28,6 +29,9 @@
_DEFAULT_NAMESPACE = 'edx_django_utils.monitoring'
_REQUEST_CACHE_NAMESPACE = f'{_DEFAULT_NAMESPACE}.custom_attributes'

_HTML_HEAD_REGEX = br"<\/head\s*>"
_HTML_BODY_REGEX = br"<body\b[^>]*>"


class DeploymentMonitoringMiddleware:
"""
Expand Down Expand Up @@ -462,6 +466,61 @@ def log_corrupt_cookie_headers(self, request, corrupt_cookie_count):
log.info(piece)


class FrontendMonitoringMiddleware:
"""
Middleware for adding the frontend monitoring scripts to the response.
"""
def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
response = self.get_response(request)

if response.status_code != 200 or not response['Content-Type'].startswith('text/html'):
return response

# .. setting_name: OPENEDX_TELEMETRY_FRONTEND_SCRIPTS
# .. setting_default: None
# .. setting_description: Scripts to inject to response for frontend monitoring, this can
# have multiple scripts as we support multiple telemetry backends at once, so we can
# provide multiple frontend scripts in a multiline string for multiple platforms tracking.
# Best is to have one at a time for better performance. This should contain HTML script tag or
# tags that will be inserted in response's HTML.
frontend_scripts = getattr(settings, 'OPENEDX_TELEMETRY_FRONTEND_SCRIPTS', None)

if not frontend_scripts:
return response

if not isinstance(frontend_scripts, str):
# Prevent a certain kind of easy mistake.
raise Exception("OPENEDX_TELEMETRY_FRONTEND_SCRIPTS must be a string.")

response.content = self.inject_script(response.content, frontend_scripts)
return response

def inject_script(self, content, script):
"""
Add script to the response, if body tag is present.
"""
body = re.search(_HTML_BODY_REGEX, content, re.IGNORECASE)

def insert_html_at_index(index):
return content[:index] + script.encode() + content[index:]

head_closing_tag = re.search(_HTML_HEAD_REGEX, content, re.IGNORECASE)

# If head tag is present, insert the monitoring scripts just before the closing of head tag
if head_closing_tag:
return insert_html_at_index(head_closing_tag.start())

# If not head tag, add scripts just before the start of body tag, if present.
if body:
return insert_html_at_index(body.start())

# Don't add the script if both head and body tag is missing.
return content


# This function should be cleaned up and made into a general logging utility, but it will first
# need some work to make it able to handle multibyte characters.
#
Expand Down
70 changes: 70 additions & 0 deletions edx_django_utils/monitoring/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from unittest.mock import Mock, call, patch

import ddt
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings
Expand All @@ -16,6 +17,7 @@
from edx_django_utils.monitoring import (
CookieMonitoringMiddleware,
DeploymentMonitoringMiddleware,
FrontendMonitoringMiddleware,
MonitoringMemoryMiddleware
)

Expand Down Expand Up @@ -322,3 +324,71 @@ def get_mock_request(self, cookies_dict):
for name, value in cookies_dict.items():
factory.cookies[name] = value
return factory.request()


@ddt.ddt
class FrontendMonitoringMiddlewareTestCase(TestCase):
"""
Tests for FrontendMonitoringMiddleware.
"""
def setUp(self):
super().setUp()
self.script = "<script>test script</script>"

@patch("edx_django_utils.monitoring.internal.middleware.FrontendMonitoringMiddleware.inject_script")
def test_frontend_middleware_without_setting_variable(self, mock_inject_script):
"""
Test that middleware behaves correctly when setting variable is not defined.
"""
original_html = '<html><head></head><body></body><html>'
middleware = FrontendMonitoringMiddleware(lambda r: HttpResponse(original_html, content_type='text/html'))
response = middleware(HttpRequest())
# Assert that the response content remains unchanged if settings not defined
assert response.content == original_html.encode()

mock_inject_script.assert_not_called()

@patch("edx_django_utils.monitoring.internal.middleware.FrontendMonitoringMiddleware.inject_script")
def test_frontend_middleware_for_json_requests(self, mock_inject_script):
"""
Test that middleware doesn't insert script tag for json requests
"""
middleware = FrontendMonitoringMiddleware(lambda r: JsonResponse({"dummy": True}))
response = middleware(HttpRequest())
# Assert that the response content remains unchanged if settings not defined
assert response.content == b'{"dummy": true}'

mock_inject_script.assert_not_called()

@ddt.data(
('<html><body></body><html>', '<body>'),
('<html><head></head><body></body><html>', '</head>'),
('<head></head><body></body>', '</head>'),
('<body></body>', '<body>'),
('<head></head>', '</head>'),
)
@ddt.unpack
def test_frontend_middleware_with_head_and_body_tag(self, original_html, expected_tag):
"""
Test that script is inserted at the right place.
"""
with override_settings(OPENEDX_TELEMETRY_FRONTEND_SCRIPTS=self.script):
middleware = FrontendMonitoringMiddleware(lambda r: HttpResponse(original_html, content_type='text/html'))
response = middleware(HttpRequest())

# Assert that the script is inserted at the right place
assert f"{self.script}{expected_tag}".encode() in response.content

@ddt.data(
'<html></html>',
'<center></center>',
)
def test_frontend_middleware_without_head_and_body_tag(self, original_html):
"""
Test that middleware behavior is correct when both of head and body tag are missing in the response.
"""
with override_settings(OPENEDX_TELEMETRY_FRONTEND_SCRIPTS=self.script):
middleware = FrontendMonitoringMiddleware(lambda r: HttpResponse(original_html, content_type='text/html'))
response = middleware(HttpRequest())
# Assert that the response content remains unchanged if no body tag is found
assert response.content == original_html.encode()

0 comments on commit bdc00e3

Please sign in to comment.