Add first version of 'phone home'

This commit is contained in:
Klaas van Schelven
2024-11-07 22:08:53 +01:00
parent 81722776c1
commit 0f5ac46362
15 changed files with 277 additions and 1 deletions

0
phonehome/__init__.py Normal file
View File

9
phonehome/admin.py Normal file
View File

@@ -0,0 +1,9 @@
from django.contrib import admin
from .models import OutboundMessage
@admin.register(OutboundMessage)
class OutboundMessageAdmin(admin.ModelAdmin):
list_display = ("attempted_at", "sent_at")
readonly_fields = ("attempted_at", "sent_at", "message")

6
phonehome/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PhonehomeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "phonehome"

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.16 on 2024-11-07 14:03
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Installation",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
],
),
]

View File

@@ -0,0 +1,17 @@
from django.db import migrations
def create_installation_id(apps, schema_editor):
Installation = apps.get_model("phonehome", "Installation")
Installation.objects.create() # id is implied (it's a uuid)
class Migration(migrations.Migration):
dependencies = [
("phonehome", "0001_initial"),
]
operations = [
migrations.RunPython(create_installation_id),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 4.2.16 on 2024-11-07 15:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("phonehome", "0002_create_installation_id"),
]
operations = [
migrations.CreateModel(
name="OutboundMessage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("attempted_at", models.DateTimeField(auto_now_add=True)),
("sent_at", models.DateTimeField(null=True)),
("message", models.TextField()),
],
),
]

View File

16
phonehome/models.py Normal file
View File

@@ -0,0 +1,16 @@
import uuid
from django.db import models
class Installation(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class OutboundMessage(models.Model):
attempted_at = models.DateTimeField(auto_now_add=True)
sent_at = models.DateTimeField(null=True)
message = models.TextField()
def __str__(self):
return f"OutboundMessage(attempted_at={self.attempted_at}, sent_at={self.sent_at})"

140
phonehome/tasks.py Normal file
View File

@@ -0,0 +1,140 @@
import logging
import requests
import platform
import json
from django.conf import settings
from django.utils import timezone
from django.contrib.auth import get_user_model
from bugsink.transaction import durable_atomic, immediate_atomic
from bugsink.version import __version__
from bugsink.app_settings import get_settings
from snappea.decorators import shared_task
from snappea.settings import get_settings as get_snappea_settings
from projects.models import Project
from teams.models import Team
from .models import Installation, OutboundMessage
logger = logging.getLogger("bugsink.phonehome")
User = get_user_model()
INTERVAL = 60 * 60 # phone-home once an hour
@shared_task
def send_if_due():
# considered: not sending if DEBUG=True. But why? Expectation is: I'm the sole user of that setting. Better send
# also, to keep symmetry, and indirectly check whether my own phone-home still works.
# Note on attempted_at / sent_at distinction: attempted_at is when we first tried to send the message, and sent_at
# is when we actually sent it. This allows us to try only once an hour, as well as track whether sending succeeded.
not_due_qs = OutboundMessage.objects.filter(
attempted_at__gte=timezone.now() - timezone.timedelta(seconds=INTERVAL)).exists()
with durable_atomic():
# We check twice to see if there's any work: once in a simple read-only transaction (which doesn't block), and
# (below) then again in the durable transaction (which can block) to actually ensure no 2 processes
# simultaneously start the process of sending the message. This incurs the cost of 2 queries in the (less
# common) case where the work is due, but it avoids the cost of blocking the whole DB for the (more common) case
# where the work is not due.
if not_due_qs:
return
with immediate_atomic():
if not_due_qs:
return
# TODO: clean up old messages (perhaps older than 1 week)
message = OutboundMessage.objects.create(
# attempted_at is auto_now_add; will be filled in automatically
message=json.dumps(_make_message_body()),
)
if not _send_message(message):
return
with immediate_atomic():
# a fresh transaction avoids hogging the DB while doing network I/O
message.sent_at = timezone.now()
message.save()
def _make_message_body():
return {
"installation_id": str(Installation.objects.get().id),
"version": __version__,
"python_version": platform.python_version(),
"settings": {
# Settings that tell us "who you are", and are relevant in the context of licensing.
"BASE_URL": get_settings().BASE_URL,
"SITE_TITLE": get_settings().SITE_TITLE,
"DEFAULT_FROM_EMAIL": settings.DEFAULT_FROM_EMAIL,
# we don't have these settings yet.
# LICENSE_KEY
# LICENSE_NAME
# LICENSE_EMAIL
# LICENSE_USERS
# LICENSE_EXPIRY
# LICENSE_TYPE
# Settings that tell us how production-like your usage is.
"SINGLE_USER": get_settings().SINGLE_USER,
"SINGLE_TEAM": get_settings().SINGLE_TEAM,
# As it stands, the 2 settings below are not used to determine production-like-ness; left here for reference
# "USER_REGISTRATION": get_settings().USER_REGISTRATION,
# "TEAM_CREATION": get_settings().TEAM_CREATION,
# Settings that tell us a bit about how Bugsink is actually deployed. Useful for support.
"TASK_ALWAYS_EAGER": get_snappea_settings().TASK_ALWAYS_EAGER,
"DIGEST_IMMEDIATELY": get_settings().DIGEST_IMMEDIATELY,
"IS_DOCKER": settings.IS_DOCKER,
"DATABASE_ENGINE": settings.DATABASES["default"]["ENGINE"],
},
"usage": {
"user_count": User.objects.count(),
"active_user_count": User.objects.filter(is_active=True).count(),
"project_count": Project.objects.count(),
"team_count": Team.objects.count(),
# event-counts [per some interval (e.g. 24 hours)] is a possible future enhancement. If that turns out to be
# expensive, one thing to consider is pulling _make_message_body() out of the immediate_atomic() block.
},
}
def _send_message(message):
url = "https://www.bugsink.com/phonehome/v1/"
def post(timeout):
response = requests.post(url, json=json.loads(message.message), timeout=timeout)
response.raise_for_status()
if get_snappea_settings().TASK_ALWAYS_EAGER:
# Doing a switch on "am I running this in snappea or not" so deep in the code somehow feels wrong, but "if it
# looks stupid but works, it ain't stupid" I guess. The point is: when doing http requests inline I want them to
# be fast enough not to bother your flow and never fail loudly; in the async case you have a bit more time, and
# failing loudly (which will be picked up, or not, in the usual ways) is actually a feature.
try:
# 1s max wait time each hour is deemed "OK"; (counterpoint: also would be on your _first_ visit)
post(timeout=1)
except requests.RequestException as e:
# This is a "soft" failure; we don't want to raise an exception, but we do want to log it.
logger.exception("Failed to send phonehome message: %s", e)
return False
else:
post(timeout=5)
return True

1
phonehome/tests.py Normal file
View File

@@ -0,0 +1 @@
# from django.test import TestCase

1
phonehome/views.py Normal file
View File

@@ -0,0 +1 @@
# from django.shortcuts import render