Repository: PostHog/posthog
Pull request: Send a weekly instance status report (resolves #1509)
## Changes

This adds a weekly (sent to us each Monday) status report regarding each PostHog instance as a whole, including event ingestion metadata.

Problem statement:
Capture metadata on ingested events @ self-hosted
Currently we have no way to track event ingestion behavior for self-hosted users (e.g. how many events are being processed on an instance per month?, what's the type of events being processed?, what are the sources of the events?, is auto-capture on?, ....)

Hints from issue discussion:

Commit message: Fix at last

Patch to review:
diff --git a/posthog/apps.py b/posthog/apps.py
index 73b771bebdea6..e25d827f2362a 100644
--- a/posthog/apps.py
+++ b/posthog/apps.py
@@ -1,11 +1,11 @@
-import hashlib
 import os
-import uuid
 
 import posthoganalytics
 from django.apps import AppConfig
 from django.conf import settings
 
+from posthog.utils import get_machine_id
+
 
 class PostHogConfig(AppConfig):
     name = "posthog"
@@ -16,10 +16,7 @@ def ready(self):
         if settings.DEBUG:
             # log development server launch to posthog
             if os.getenv("RUN_MAIN") == "true":
-                # MAC addresses are 6 bits long, so overflow shouldn't happen
-                # hashing here as we don't care about the actual address, just it being rather consistent
-                mac_address_hash = hashlib.md5(uuid.getnode().to_bytes(6, "little"))
-                posthoganalytics.capture(mac_address_hash.hexdigest(), "development server launched")
+                posthoganalytics.capture(get_machine_id(), "development server launched")
             posthoganalytics.disabled = True
         elif settings.TEST or os.environ.get("OPT_OUT_CAPTURE"):
             posthoganalytics.disabled = True
diff --git a/posthog/celery.py b/posthog/celery.py
index 406398fa1db0b..a47d4358acea6 100644
--- a/posthog/celery.py
+++ b/posthog/celery.py
@@ -1,7 +1,5 @@
 import os
 import time
-from datetime import datetime
-from typing import Optional
 
 import redis
 import statsd  # type: ignore
@@ -10,6 +8,7 @@
 from dateutil import parser
 from django.conf import settings
 from django.db import connection
+from django.utils import timezone
 
 from posthog.settings import STATSD_HOST, STATSD_PORT, STATSD_PREFIX
 
@@ -48,6 +47,9 @@ def setup_periodic_tasks(sender, **kwargs):
     sender.add_periodic_task(
         crontab(day_of_week="mon,fri"), update_event_partitions.s(),  # check twice a week
     )
+    sender.add_periodic_task(
+        10, status_report.s(),
+    )
     sender.add_periodic_task(15 * 60, calculate_cohort.s(), name="debug")
     sender.add_periodic_task(600, check_cached_items.s(), name="check dashboard items")
 
@@ -85,6 +87,13 @@ def update_event_partitions():
         )
 
 
+@app.task
+def status_report():
+    from posthog.tasks.status_report import status_report
+
+    status_report()
+
+
 @app.task
 def calculate_event_action_mappings():
     from posthog.tasks.calculate_action import calculate_actions_from_last_calculation
diff --git a/posthog/tasks/status_report.py b/posthog/tasks/status_report.py
new file mode 100644
index 0000000000000..607184df49188
--- /dev/null
+++ b/posthog/tasks/status_report.py
@@ -0,0 +1,19 @@
+import logging
+
+import posthoganalytics
+from django.utils import timezone
+
+from posthog.models import Event
+from posthog.utils import get_machine_id
+
+logger = logging.getLogger(__name__)
+
+
+def status_report() -> None:
+    period_end = (timezone.now() - timezone.timedelta(timezone.now().weekday())).replace(
+        hour=0, minute=0, second=0, microsecond=0
+    )  # very start of the current Monday
+    period_start = period_end - timezone.timedelta(7)  # very start of the Monday preceding the current one
+    events_considered = Event.objects.filter(created_at__gte=period_start, created_at_lt=period_end)
+    report = {"period": [period_start.isoformat(), period_end.isoformat()], "event_count": events_considered.count()}
+    posthoganalytics.capture(get_machine_id(), "instance status report", report)
diff --git a/posthog/utils.py b/posthog/utils.py
index 163c9bb4e8c77..3e3ac45d1ea44 100644
--- a/posthog/utils.py
+++ b/posthog/utils.py
@@ -8,6 +8,7 @@
 import re
 import subprocess
 import time
+import uuid
 from typing import Any, Dict, List, Optional, Tuple, Union
 from urllib.parse import urlparse, urlsplit
 
@@ -18,7 +19,6 @@
 from dateutil.relativedelta import relativedelta
 from django.apps import apps
 from django.conf import settings
-from django.contrib.auth.models import AnonymousUser
 from django.http import HttpRequest, HttpResponse, JsonResponse
 from django.template.loader import get_template
 from django.utils import timezone
@@ -313,6 +313,8 @@ def authenticate(self, request: Request):
 
 class PublicTokenAuthentication(authentication.BaseAuthentication):
     def authenticate(self, request: Request):
+        from django.contrib.auth.models import AnonymousUser
+
         if request.GET.get("share_token") and request.parser_context and request.parser_context.get("kwargs"):
             Dashboard = apps.get_model(app_label="posthog", model_name="Dashboard")
             dashboard = Dashboard.objects.filter(
@@ -425,3 +427,10 @@ def __call__(self, *args, **kwds):
         if self.instance == None:
             self.instance = self.klass(*args, **kwds)
         return self.instance
+
+
+def get_machine_id() -> str:
+    """A MAC address-dependent ID. Useful for PostHog instance analytics."""
+    # MAC addresses are 6 bits long, so overflow shouldn't happen
+    # hashing here as we don't care about the actual address, just it being rather consistent
+    return hashlib.md5(uuid.getnode().to_bytes(6, "little")).hexdigest()


Code review comments:
diff_hunk: @@ -48,6 +47,9 @@ def setup_periodic_tasks(sender, **kwargs):
     sender.add_periodic_task(
         crontab(day_of_week="mon,fri"), update_event_partitions.s(),  # check twice a week
     )
+    sender.add_periodic_task(
+        10, status_report.s(),
path: posthog/celery.py
text: @user1:
This would send the report every 10 seconds, perhaps a cron schedule here? Also, what should we do with retries or if the server is down during the scheduled time (e.g. Heroku free dynos). Ideally we would also have a failsafe on PostHog side to prevent duplicate entries for the same week.

@user1:
Related to the above, we might have the edge case of a user having multiple instances or workers, so event duplication prevention might become more relevant (doing reports on a team-basis would also help here).

@author:
Ah, sorry, this is a leftover from testing, supposed to be `crontab(day_of_week="mon")`. :)

diff_hunk: @@ -0,0 +1,19 @@
+import logging
+
+import posthoganalytics
+from django.utils import timezone
+
+from posthog.models import Event
+from posthog.utils import get_machine_id
+
+logger = logging.getLogger(__name__)
+
+
+def status_report() -> None:
+    period_end = (timezone.now() - timezone.timedelta(timezone.now().weekday())).replace(
path: posthog/tasks/status_report.py
text: @user1:
Maybe it's better to do from the start of Monday to the end of Sunday? Something like,

```python
    period_end = datetime.datetime.combine(
        timezone.now() - timezone.timedelta(timezone.now().weekday() + 1),
        datetime.time.max,
    )  # very end of the previous Sunday
    period_start = datetime.datetime.combine(
        period_end - timezone.timedelta(6), datetime.time.min,
    )  # very start of the Monday preceding the current one
```

@author:
I started off this way, but then thought it's more elegant if min and max are exactly 1 week apart, and the filtering is "greater than" combined with "less than or equal".

diff_hunk: @@ -0,0 +1,19 @@
+import logging
+
+import posthoganalytics
+from django.utils import timezone
+
+from posthog.models import Event
+from posthog.utils import get_machine_id
+
+logger = logging.getLogger(__name__)
+
+
+def status_report() -> None:
+    period_end = (timezone.now() - timezone.timedelta(timezone.now().weekday())).replace(
+        hour=0, minute=0, second=0, microsecond=0
+    )  # very start of the current Monday
+    period_start = period_end - timezone.timedelta(7)  # very start of the Monday preceding the current one
+    events_considered = Event.objects.filter(created_at__gte=period_start, created_at_lt=period_end)
+    report = {"period": [period_start.isoformat(), period_end.isoformat()], "event_count": events_considered.count()}
+    posthoganalytics.capture(get_machine_id(), "instance status report", report)
path: posthog/tasks/status_report.py
text: @user1:
A few points here,
- I think it might be a bit better to do a team-based report in case the instance has multiple teams and just use the machine ID as a property to relate them to a single instance, that way we can do analyses on a team-scope or instance-scope.
- We need a way to relate these teams/machines to specific users, as we do analyses on a user-basis.
- I believe it'd be useful to add some additional metadata information on the events captured like,
    - The number of events coming from each library.
    - The number of events using autocapture.
    - The number of active persons / users.
    - The version of PostHog being used.

@author:
Yep, absolutely, this was just an initial version. I now added PostHog version, a breakdown of event count by event name (which is useful for analyzing `$autocapture` usage, but also other events), a breakdown of event count by integration library, and a list of users who logged in during the time period in consideration. Open to more enhancements.