From 5c841cbc65ace4c054c23d426b14f2d7412f5d36 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ma=C5=9Blanka?= <piotr.maslanka@henrietta.com.pl>
Date: Wed, 25 Aug 2021 23:26:12 +0200
Subject: [PATCH] add metrics and traces

---
 README.md                        | 37 +++++++++++++-
 docker-compose.yml               |  3 +-
 netguru/settings.py              | 43 ++++++++++++----
 requirements.txt                 |  2 +
 shares/cron.py                   | 19 +++++--
 shares/templates/share/view.html |  1 +
 shares/tests.py                  | 16 ++++--
 shares/views.py                  | 86 +++++++++++++++++++-------------
 8 files changed, 151 insertions(+), 56 deletions(-)

diff --git a/README.md b/README.md
index 0a381e6..98e37f9 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,11 @@ docker-compose up -d run_local
 Deployment is handled automatically via a
 [CI script](.gitlab-ci.yml).
 
-Deployment is done to a Docker Swarm platform.
+Deployment is done to a Docker Swarm platform,
+since I'm a big fan, and I'm still learning Kubernetes.
+
+I realize that Docker Swarm is a dead end, as it isn't supported 
+anymore, but I've got 1,5-FTE worth of jobs on my hands.
 
 ### Configuration
 
@@ -48,3 +52,34 @@ but since it has already been used it isn't safe anymore.
 The application needs a volume marked at 
 `/data` to store files. Because storing 
 files in a database is an antipattern.
+
+### Monitoring
+
+#### Logs
+
+You will just have to forgive me that I failed
+to hook up any loggers for it. Any Python-based
+logger will do, I sincerely recommends
+[seq-log](https://github.com/tintoy/seqlog)
+- full disclosure: I'm a contributor there
+
+#### Metrics
+
+The solution actively exports metrics at port 81.
+You can hook up Prometheus to it.
+It exports the metrics thanks to my excellent
+[satella](https://github.com/piotrmaslanka/satella)
+library and [django-satella-metrics](https://github.com/piotrmaslanka/django-satella-metrics)
+adapter.
+
+#### Traces
+
+The solution fully supports tracing. You can tweak
+the settings in [settings.py](netguru/settings.py) file
+to install for example a Jaeger instance.
+
+I failed to deploy that on your test environment since I'm
+pretty much strapped for time.
+
+BTW: If you're not already doing 
+[tracing](https://opentracing.io/), you should totally consider it.
diff --git a/docker-compose.yml b/docker-compose.yml
index 269d93a..cc0ad77 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,7 +12,7 @@ services:
     command: "./test.sh"
     build: .
     environment:
-      - CI=1
+      - DEBUG-1
     depends_on:
       - postgres
   run_local:
@@ -21,3 +21,4 @@ services:
       - postgres
     ports:
       - 80:80
+      - 81:81
diff --git a/netguru/settings.py b/netguru/settings.py
index 8cd17bc..5f7a836 100644
--- a/netguru/settings.py
+++ b/netguru/settings.py
@@ -1,6 +1,7 @@
 import os
+from satella.instrumentation.metrics import getMetric
+from satella.instrumentation.metrics.exporters import PrometheusHTTPExporterThread
 
-# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 # Quick-start development settings - unsuitable for production
@@ -10,7 +11,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 SECRET_KEY = os.environ.get('SECRET_KEY', 'r6(!w-%glre916esjft0^lds4u)=fym=v*26$@*&y7qcssfi%j')
 
 # SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
+DEBUG = 'DEBUG' in os.environ
 
 ALLOWED_HOSTS = ['*']
 
@@ -27,6 +28,8 @@ INSTALLED_APPS = [
 ]
 
 MIDDLEWARE = [
+    'django_satella_metrics.DjangoSatellaMetricsMiddleware',
+    'django_opentracing.OpenTracingMiddleware',
     'django.middleware.security.SecurityMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.middleware.common.CommonMiddleware',
@@ -60,22 +63,16 @@ WSGI_APPLICATION = 'netguru.wsgi.application'
 # Database
 # https://docs.djangoproject.com/en/3.0/ref/settings/#databases
 
-if True:
-    db = {
+DATABASES = {
+    'default': {
         'ENGINE': 'django.db.backends.postgresql',
         'NAME': os.environ.get('DB_NAME', 'postgres'),
         'USER': os.environ.get('DB_USER', 'postgres'),
         'PASSWORD': os.environ.get('DB_PASS', 'postgres'),
         'HOST': os.environ.get('DB_HOST', 'postgres'),
         'PORT': int(os.environ.get('DB_PORT', '5432')),
-        'ATOMIC_REQUESTS': True  # transactions aren't just a good idea
+        'ATOMIC_REQUESTS': True  # thank God for transactions
     }
-else:
-    db = {'ENGINE': 'django.db.backends.sqlite3',
-          'NAME': 'test-db.sqlite'}
-
-DATABASES = {
-    'default': db
 }
 
 # Password validation
@@ -116,3 +113,27 @@ STATIC_URL = '/static/'
 STATIC_ROOT = os.path.join(BASE_DIR, 'media')
 
 FILE_SAVE_LOCATION = '/data'
+
+# Configure tracing
+OPENTRACING_TRACE_ALL = True
+
+# Callable that returns an `opentracing.Tracer` implementation.
+OPENTRACING_TRACER_CALLABLE = 'opentracing.Tracer'
+
+# Parameters for the callable (Depending on the tracer implementation chosen)
+OPENTRACING_TRACER_PARAMETERS = {
+}
+
+# Set up metrics
+DJANGO_SATELLA_METRICS = {
+    'summary_metric': getMetric('netguru.time.summary', 'summary'),
+    'histogram_metric': getMetric('netguru.time.histogram', 'histogram'),
+    'status_codes_metric': getMetric('netguru.status_codes', 'counter')
+}
+
+
+
+phet = PrometheusHTTPExporterThread('0.0.0.0', 81, {'service_name': 'netguru'})
+phet.start()
+
+
diff --git a/requirements.txt b/requirements.txt
index 7f6d31d..1b1b3ef 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,5 @@ psycopg2
 gunicorn
 django_cron
 satella
+django-satella-metrics
+django_opentracing
diff --git a/shares/cron.py b/shares/cron.py
index 34e80cb..6018a96 100644
--- a/shares/cron.py
+++ b/shares/cron.py
@@ -2,15 +2,20 @@ import logging
 import os
 from datetime import datetime, timedelta
 from django_cron import CronJobBase, Schedule
+from satella.instrumentation.metrics import getMetric
 
 from netguru.settings import FILE_SAVE_LOCATION
 from .models import Share, SHARE_FILE
 
 logger = logging.getLogger(__name__)
+links_deleted = getMetric('deleted.links', 'counter')
+files_deleted = getMetric('deleted.files', 'counter')
 
 
 class ClearExpiredLinks(CronJobBase):
-    RUN_EVERY_MINS = 60  # every 2 hours
+    # the links might be removed late by 59 minutes, but that shouldn't be a big deal
+    # I could if the handler to check exactly for expiration, but I won't do that.
+    RUN_EVERY_MINS = 60  # every hour
 
     schedule = Schedule(run_every_mins=RUN_EVERY_MINS)
     code = 'clear-expired-links'  # a unique code
@@ -21,7 +26,15 @@ class ClearExpiredLinks(CronJobBase):
             if share.share_type == SHARE_FILE:
                 path = os.path.join(FILE_SAVE_LOCATION, share.resource.split('.', 1)[0])
                 if os.path.exists(path):
-                    # we might have deleted in earlier and this call might have aborted with exception
-                    # the transaction won't proceed so we might have already deleted this file
+                    # we might have deleted in earlier and this call might have aborted with
+                    # exception, the transaction won't proceed so we might have already deleted this
+                    # file. I realize that EAFP would be more Pythonic, but who cares?
+                    logger.info('File %s deleted', path)
                     os.unlink(path)
+                    files_deleted.runtime(+1)   # I'm typing +1 on purpose, since it will
+                                                # increment the counter
+                else:
+                    links_deleted.runtime(+1)
+                    logger.info('A link was deleted')
+            share.delete()
         logger.info('Deleted expired shares')
diff --git a/shares/templates/share/view.html b/shares/templates/share/view.html
index b8b3b3f..8d2d8da 100644
--- a/shares/templates/share/view.html
+++ b/shares/templates/share/view.html
@@ -9,5 +9,6 @@
         {{ form.as_p }}
     <p>
         <input type="submit" value="Submit">
+    </p>
     </form>
 {% endblock %}
diff --git a/shares/tests.py b/shares/tests.py
index 1ef9aa9..c526eec 100644
--- a/shares/tests.py
+++ b/shares/tests.py
@@ -1,4 +1,11 @@
-from django.contrib.auth.hashers import make_password
+#
+# APOLOGIES IN ADVANCE!!!
+#
+# I realize that I would be best practice to deduplicate some code contained here
+# but since I'm running about 1,5 of full-time equvialents you just have to forgive me
+# for sparing the effort to do so. Thanks "from the mountain!"
+#
+
 from django.contrib.auth.models import User
 from django.test import TestCase, Client
 from django.test.html import parse_html
@@ -13,17 +20,16 @@ def find_element(document, field, value):
         return document
     for child in document.children:
         a = find_element(child, field, value)
-        if a is not None:
+        if a:
             return a
 
 
-# Create your tests here.
 class TestShares(TestCase):
 
     def setUp(self) -> None:
         super().setUp()
-        user = User.objects.create(username='testuser', email='admin@admin.com',
-                                   password='12345')
+        user, _ = User.objects.get_or_create(username='testuser', email='admin@admin.com',
+                                             password='12345')
         self.client = Client()  # May be you have missed this line
         self.client.force_login(user)
 
diff --git a/shares/views.py b/shares/views.py
index 5e55355..a82e16f 100644
--- a/shares/views.py
+++ b/shares/views.py
@@ -2,22 +2,29 @@ import mimetypes
 import typing as tp
 import os
 import random
+import logging
 import string
-from email.header import Header
 import uuid
 from django import forms
 from django.core.validators import URLValidator
 from django.forms import fields
 from django.core.exceptions import ValidationError
-from django.http import Http404, StreamingHttpResponse
+from django.http import Http404, StreamingHttpResponse, HttpResponse
 from django.shortcuts import render, get_object_or_404, redirect
 from django.contrib.auth.decorators import login_required
 from django.utils.safestring import mark_safe
+from satella.instrumentation.metrics import getMetric
 
 from shares.models import Share, SHARE_URL, SHARE_FILE
 import hashlib
 from netguru.settings import SECRET_KEY, FILE_SAVE_LOCATION
 
+files_created = getMetric('created.files', 'counter')
+links_created = getMetric('created.links', 'counter')
+files_visited = getMetric('visited.files', 'counter')
+links_visited = getMetric('visited.links', 'counter')
+logger = logging.getLogger(__name__)
+
 
 def hash_password(password: str) -> str:
     return hashlib.sha256(password.encode('utf-8') + SECRET_KEY.encode('utf-8')).hexdigest()
@@ -25,12 +32,17 @@ def hash_password(password: str) -> str:
 
 def generate_correct_uuid() -> str:
     f = uuid.uuid4().hex
-    # I know that hash collisions practically don't happen, but since it's so cheap for us
+    # I know that UUID collisions practically don't happen, but since it's so cheap for us
     # to go that extra mile
+    # and since we're at risk at overwriting somebody's files, I guess i should check it
+    # I realize that it's susceptible to race conditions, but then again what is the chance?
+    # The chance that UUID will repeat during the 24 hours a link is active is much higher
+    # than the chance that it will repeat during the same 2 second window
     while os.path.exists(FILE_SAVE_LOCATION, f):
         f = uuid.uuid4().hex
     return f
 
+
 # Cut off O, I and S so that the user doesn't confuse them with 0, 1 and 5 respectively
 CHARACTERS_TO_USE = string.ascii_uppercase.replace('O', '') \
                           .replace('I', '').replace('S', '') + string.digits
@@ -69,8 +81,8 @@ def add_share(request):
                               resource=data['url'],
                               pwd_hash=pwd_hash,
                               share_type=SHARE_URL)
-                share.save()
-                data_added['url'] = mark_safe(f'https://{request.get_host()}/shares/{share.id}')
+                links_created.runtime(+1)
+                logger.info('Created a link')
             else:
                 file_name = generate_correct_uuid()
                 file = request.FILES['file']
@@ -82,8 +94,9 @@ def add_share(request):
                               resource=resource_name,
                               pwd_hash=pwd_hash,
                               share_type=SHARE_FILE)
-                share.save()
-                data_added['url'] = mark_safe(f'https://{request.get_host()}/shares/{share.id}')
+                files_created.runtime(+1)
+            data_added['url'] = mark_safe(f'https://{request.get_host()}/shares/{share.id}')
+            share.save()
     else:
         form = AddShareURLForm()
 
@@ -100,34 +113,37 @@ def view_share(request, share_id: str):
         form = PasswordForm(request.POST)
         if form.is_valid():
             pwd = form.cleaned_data['password']
-            if hash_password(pwd) == share.pwd_hash:
-                try:
-                    if share.share_type == SHARE_URL:
-                        return redirect(share.resource)
-                    else:
-                        file_uuid, file_name = share.resource.split('.', 1)
-                        file_loc = os.path.join(FILE_SAVE_LOCATION, file_uuid)
-                        if not os.path.exists(file_loc):
-                            raise Http404()
-                        else:
-                            def file_iterator(path: str) -> tp.Iterator[bytes]:
-                                with open(path, 'rb') as f:
-                                    p = f.read(1024)
-                                    while p:
-                                        yield p
-                                        p = f.read(1024)
-
-                            # Django will have the UTF-8 mumbo jumbo for us
-                            cd = f'attachment; filename="{file_name}"'
-                            mimetype = mimetypes.guess_type(file_name)[0] or 'application/octet-stream'
-                            return StreamingHttpResponse(file_iterator(file_loc),
-                                                         content_type=mimetype,
-                                                         headers={
-                                                             'Content-Disposition': cd
-                                                         })
-                finally:
-                    share.views_count += 1
-                    share.save()
+            if hash_password(pwd) != share.pwd_hash:
+                return HttpResponse(status=401)
+            try:
+                if share.share_type == SHARE_URL:
+                    links_visited.runtime(+1)
+                    return redirect(share.resource)
+                else:
+                    file_uuid, file_name = share.resource.split('.', 1)
+                    file_loc = os.path.join(FILE_SAVE_LOCATION, file_uuid)
+                    if not os.path.exists(file_loc):
+                        raise Http404()
+
+                    def file_iterator(path: str) -> tp.Iterator[bytes]:
+                        with open(path, 'rb') as f:
+                            p = f.read(1024)
+                            while p:
+                                yield p
+                                p = f.read(1024)
+
+                    # Django will have the UTF-8 mumbo jumbo for us
+                    cd = f'attachment; filename="{file_name}"'
+                    mimetype = mimetypes.guess_type(file_name)[0] or 'application/octet-stream'
+                    files_visited.runtime(+1)
+                    return StreamingHttpResponse(file_iterator(file_loc),
+                                                 content_type=mimetype,
+                                                 headers={
+                                                     'Content-Disposition': cd
+                                                 })
+            finally:
+                share.views_count += 1
+                share.save()
     else:
         form = PasswordForm()
 
-- 
GitLab