From b743afd8d4cac035b10646021c0f9358abea310b Mon Sep 17 00:00:00 2001
From: Bram Wiepjes <bramw@protonmail.com>
Date: Mon, 24 Jul 2023 11:11:14 +0000
Subject: [PATCH] Implement posthog product analytics

---
 backend/requirements/base.in                  |  1 +
 backend/requirements/base.txt                 | 11 ++-
 backend/requirements/dev.in                   |  2 +-
 backend/requirements/dev.txt                  |  2 +-
 backend/src/baserow/config/settings/base.py   | 10 +++
 .../contrib/database/airtable/utils.py        |  4 +-
 backend/src/baserow/core/apps.py              |  3 +
 backend/src/baserow/core/posthog.py           | 71 +++++++++++++++++++
 .../database/airtable/test_airtable_utils.py  |  4 ++
 backend/tests/baserow/core/test_posthog.py    | 35 +++++++++
 ...nt_optional_posthog_product_analytics.json |  7 ++
 docker-compose.local-build.yml                |  2 +
 docker-compose.no-caddy.yml                   |  2 +
 docker-compose.yml                            |  2 +
 docs/installation/configuration.md            |  7 ++
 .../airtable/ImportFromAirtable.vue           |  2 +-
 16 files changed, 159 insertions(+), 6 deletions(-)
 create mode 100644 backend/src/baserow/core/posthog.py
 create mode 100644 backend/tests/baserow/core/test_posthog.py
 create mode 100644 changelog/entries/unreleased/feature/implement_optional_posthog_product_analytics.json

diff --git a/backend/requirements/base.in b/backend/requirements/base.in
index 41cf764bc..3e19123af 100644
--- a/backend/requirements/base.in
+++ b/backend/requirements/base.in
@@ -61,4 +61,5 @@ Brotli==1.0.9
 loguru==0.6.0
 django-cachalot==2.5.3
 celery-singleton==0.3.1
+posthog==3.0.1
 https://github.com/fellowapp/prosemirror-py/archive/refs/tags/v0.3.5.zip
diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt
index 790de9ed2..51f12a3e6 100644
--- a/backend/requirements/base.txt
+++ b/backend/requirements/base.txt
@@ -37,7 +37,9 @@ azure-core==1.26.4
 azure-storage-blob==12.16.0
     # via django-storages
 backoff==2.2.1
-    # via opentelemetry-exporter-otlp-proto-http
+    # via
+    #   opentelemetry-exporter-otlp-proto-http
+    #   posthog
 billiard==3.6.4.0
     # via celery
 boto3==1.26.103
@@ -211,6 +213,8 @@ kombu==5.2.4
     # via celery
 loguru==0.6.0
     # via -r base.in
+monotonic==1.6
+    # via posthog
 msgpack==1.0.4
     # via channels-redis
 ndg-httpsclient==0.5.1
@@ -315,6 +319,8 @@ opentelemetry-util-http==0.38b0
     #   opentelemetry-instrumentation-wsgi
 pillow==9.4.0
     # via -r base.in
+posthog==3.0.1
+    # via -r base.in
 prompt-toolkit==3.0.31
     # via click-repl
 prosemirror @ https://github.com/fellowapp/prosemirror-py/archive/refs/tags/v0.3.5.zip
@@ -360,6 +366,7 @@ python-dateutil==2.8.2
     #   botocore
     #   celery-redbeat
     #   faker
+    #   posthog
     #   pysaml2
     #   python-crontab
 python-dotenv==0.21.0
@@ -393,6 +400,7 @@ requests==2.31.0
     #   google-api-core
     #   google-cloud-storage
     #   opentelemetry-exporter-otlp-proto-http
+    #   posthog
     #   pysaml2
     #   requests-oauthlib
 requests-oauthlib==1.3.1
@@ -413,6 +421,7 @@ six==1.16.0
     #   click-repl
     #   google-auth
     #   isodate
+    #   posthog
     #   python-dateutil
     #   service-identity
 sniffio==1.3.0
diff --git a/backend/requirements/dev.in b/backend/requirements/dev.in
index a3c54f6f3..0503711d5 100644
--- a/backend/requirements/dev.in
+++ b/backend/requirements/dev.in
@@ -23,7 +23,7 @@ pytest-html==3.2.0
 coverage==7.2.2
 pytest-split==0.8.0
 bandit==1.7.5
-pip-tools==6.12.3
+pip-tools==6.13.0
 autopep8==2.0.2
 pytest-unordered==0.5.2
 debugpy==1.6.6
diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt
index d5465a4c5..f8ef9a7e9 100644
--- a/backend/requirements/dev.txt
+++ b/backend/requirements/dev.txt
@@ -183,7 +183,7 @@ pexpect==4.8.0
     # via ipython
 pickleshare==0.7.5
     # via ipython
-pip-tools==6.12.3
+pip-tools==6.13.0
     # via -r dev.in
 platformdirs==2.5.2
     # via black
diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py
index 6fe417e2c..fd89eac31 100644
--- a/backend/src/baserow/config/settings/base.py
+++ b/backend/src/baserow/config/settings/base.py
@@ -12,6 +12,7 @@ from urllib.parse import urljoin, urlparse
 from django.core.exceptions import ImproperlyConfigured
 
 import dj_database_url
+import posthog
 from corsheaders.defaults import default_headers
 
 from baserow.cachalot_patch import patch_cachalot_for_baserow
@@ -1108,6 +1109,15 @@ USE_PG_FULLTEXT_SEARCH = str_to_bool(
 PG_SEARCH_CONFIG = os.getenv("BASEROW_PG_SEARCH_CONFIG", "simple")
 AUTO_VACUUM_AFTER_SEARCH_UPDATE = str_to_bool(os.getenv("BASEROW_AUTO_VACUUM", "true"))
 
+POSTHOG_PROJECT_API_KEY = os.getenv("POSTHOG_PROJECT_API_KEY", "")
+POSTHOG_HOST = os.getenv("POSTHOG_HOST", "")
+POSTHOG_ENABLED = POSTHOG_PROJECT_API_KEY and POSTHOG_HOST
+if POSTHOG_ENABLED:
+    posthog.project_api_key = POSTHOG_PROJECT_API_KEY
+    posthog.host = POSTHOG_HOST
+else:
+    posthog.disabled = True
+
 # Indicates whether we are running the tests or not. Set to True in the test.py settings
 # file used by pytest.ini
 TESTS = False
diff --git a/backend/src/baserow/contrib/database/airtable/utils.py b/backend/src/baserow/contrib/database/airtable/utils.py
index a2d6090a1..72dc056fc 100644
--- a/backend/src/baserow/contrib/database/airtable/utils.py
+++ b/backend/src/baserow/contrib/database/airtable/utils.py
@@ -11,7 +11,7 @@ def extract_share_id_from_url(public_base_url: str) -> str:
     :return: The extracted share id.
     """
 
-    result = re.search(r"https:\/\/airtable.com\/shr(.*)$", public_base_url)
+    result = re.search(r"https:\/\/airtable.com\/(shr|app)(.*)$", public_base_url)
 
     if not result:
         raise ValueError(
@@ -19,4 +19,4 @@ def extract_share_id_from_url(public_base_url: str) -> str:
             f"https://airtable.com/shrxxxxxxxxxxxxxx)"
         )
 
-    return f"shr{result.group(1)}"
+    return f"{result.group(1)}{result.group(2)}"
diff --git a/backend/src/baserow/core/apps.py b/backend/src/baserow/core/apps.py
index ec2235d0b..980429cf2 100755
--- a/backend/src/baserow/core/apps.py
+++ b/backend/src/baserow/core/apps.py
@@ -286,6 +286,9 @@ class CoreConfig(AppConfig):
             WorkspaceInvitationRejectedNotificationType()
         )
 
+        # Must import the Posthog signal, otherwise it won't work.
+        import baserow.core.posthog  # noqa: F403, F401
+
         self._setup_health_checks()
 
         # Clear the key after migration so we will trigger a new template sync.
diff --git a/backend/src/baserow/core/posthog.py b/backend/src/baserow/core/posthog.py
new file mode 100644
index 000000000..22d30e18b
--- /dev/null
+++ b/backend/src/baserow/core/posthog.py
@@ -0,0 +1,71 @@
+from copy import deepcopy
+from typing import Optional
+
+from django.conf import settings
+from django.contrib.auth.models import AbstractUser
+from django.dispatch import receiver
+
+import posthog
+
+from baserow.core.action.signals import ActionCommandType, action_done
+from baserow.core.models import Workspace
+
+
+def capture_event(
+    user: AbstractUser,
+    event: str,
+    properties: dict,
+    session: Optional[str] = None,
+    workspace: Optional[Workspace] = None,
+):
+    """
+    Captures a Posthog event in a consistent property format.
+
+    :param user: The user that performed the event.
+    :param event: Unique name identifying the event.
+    :param properties: A dictionary containing the properties that must be added.
+        Note that the `user_email`, `user_session`, `workspace_id`,
+        and `workspace_name` will be overwritten.
+    :param session: A unique session id that identifies the user throughout their
+        session.
+    :param workspace: Optionally the workspace related to the event.
+    """
+
+    if not settings.POSTHOG_ENABLED:
+        return
+
+    properties = deepcopy(properties)
+    properties["user_email"] = user.email
+
+    if session is not None:
+        properties["user_session"] = session
+
+    if workspace is not None:
+        properties["workspace_id"] = workspace.id
+        properties["workspace_name"] = workspace.name
+
+    posthog.capture(
+        distinct_id=user.id,
+        event=event,
+        properties=properties,
+    )
+
+
+@receiver(action_done)
+def capture_event_action_done(
+    sender,
+    user,
+    action_type,
+    action_params,
+    action_timestamp,
+    action_command_type,
+    workspace,
+    session,
+    **kwargs,
+):
+    # Only capture do commands for now because the undo might make it more difficult
+    # to do analytics on the data.
+    if action_command_type == ActionCommandType.DO:
+        capture_event(
+            user, action_type.type, action_params, workspace=workspace, session=session
+        )
diff --git a/backend/tests/baserow/contrib/database/airtable/test_airtable_utils.py b/backend/tests/baserow/contrib/database/airtable/test_airtable_utils.py
index bad6177dd..cde32ddff 100644
--- a/backend/tests/baserow/contrib/database/airtable/test_airtable_utils.py
+++ b/backend/tests/baserow/contrib/database/airtable/test_airtable_utils.py
@@ -11,6 +11,10 @@ def test_extract_share_id_from_url():
         extract_share_id_from_url("https://airtable.com/shrxxxxxxxxxxxxxx")
         == "shrxxxxxxxxxxxxxx"
     )
+    assert (
+        extract_share_id_from_url("https://airtable.com/appxxxxxxxxxxxxxx")
+        == "appxxxxxxxxxxxxxx"
+    )
     assert (
         extract_share_id_from_url("https://airtable.com/shrXxmp0WmqsTkFWTzv")
         == "shrXxmp0WmqsTkFWTzv"
diff --git a/backend/tests/baserow/core/test_posthog.py b/backend/tests/baserow/core/test_posthog.py
new file mode 100644
index 000000000..64915996e
--- /dev/null
+++ b/backend/tests/baserow/core/test_posthog.py
@@ -0,0 +1,35 @@
+from unittest.mock import patch
+
+from django.test.utils import override_settings
+
+import pytest
+
+from baserow.core.posthog import capture_event
+
+
+@pytest.mark.django_db
+@override_settings(POSTHOG_ENABLED=False)
+@patch("baserow.core.posthog.posthog")
+def test_not_capture_event_if_not_enabled(mock_posthog, data_fixture):
+    user = data_fixture.create_user()
+    capture_event(user, "test", {})
+    mock_posthog.capture.assert_not_called()
+
+
+@pytest.mark.django_db
+@override_settings(POSTHOG_ENABLED=True)
+@patch("baserow.core.posthog.posthog")
+def test_capture_event_if_enabled(mock_posthog, data_fixture):
+    user = data_fixture.create_user()
+    workspace = data_fixture.create_workspace()
+    capture_event(user, "test", {}, session="session", workspace=workspace)
+    mock_posthog.capture.assert_called_once_with(
+        distinct_id=user.id,
+        event="test",
+        properties={
+            "user_email": user.email,
+            "user_session": "session",
+            "workspace_id": workspace.id,
+            "workspace_name": workspace.name,
+        },
+    )
diff --git a/changelog/entries/unreleased/feature/implement_optional_posthog_product_analytics.json b/changelog/entries/unreleased/feature/implement_optional_posthog_product_analytics.json
new file mode 100644
index 000000000..b53331c72
--- /dev/null
+++ b/changelog/entries/unreleased/feature/implement_optional_posthog_product_analytics.json
@@ -0,0 +1,7 @@
+{
+    "type": "feature",
+    "message": "Implemented optional Posthog product analytics.",
+    "issue_number": null,
+    "bullet_points": [],
+    "created_at": "2023-07-21"
+}
diff --git a/docker-compose.local-build.yml b/docker-compose.local-build.yml
index 85540c1c3..7029fbbc2 100644
--- a/docker-compose.local-build.yml
+++ b/docker-compose.local-build.yml
@@ -78,6 +78,8 @@ x-backend-variables: &backend-variables
   BASEROW_DEPLOYMENT_ENV:
   OTEL_EXPORTER_OTLP_ENDPOINT:
   OTEL_RESOURCE_ATTRIBUTES:
+  POSTHOG_PROJECT_API_KEY:
+  POSTHOG_HOST:
 
   PRIVATE_BACKEND_URL: http://backend:8000
   PUBLIC_BACKEND_URL:
diff --git a/docker-compose.no-caddy.yml b/docker-compose.no-caddy.yml
index f0e25a00c..b7224b59b 100644
--- a/docker-compose.no-caddy.yml
+++ b/docker-compose.no-caddy.yml
@@ -98,6 +98,8 @@ x-backend-variables: &backend-variables
   BASEROW_DEPLOYMENT_ENV:
   OTEL_EXPORTER_OTLP_ENDPOINT:
   OTEL_RESOURCE_ATTRIBUTES:
+  POSTHOG_PROJECT_API_KEY:
+  POSTHOG_HOST:
 
   PRIVATE_BACKEND_URL: http://backend:8000
   BASEROW_PUBLIC_URL:
diff --git a/docker-compose.yml b/docker-compose.yml
index 488af6e88..83215b8b3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -94,6 +94,8 @@ x-backend-variables: &backend-variables
   BASEROW_DEPLOYMENT_ENV:
   OTEL_EXPORTER_OTLP_ENDPOINT:
   OTEL_RESOURCE_ATTRIBUTES:
+  POSTHOG_PROJECT_API_KEY:
+  POSTHOG_HOST:
 
   PRIVATE_BACKEND_URL: http://backend:8000
   PUBLIC_BACKEND_URL:
diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md
index 971d863b6..7e0d76331 100644
--- a/docs/installation/configuration.md
+++ b/docs/installation/configuration.md
@@ -262,3 +262,10 @@ domain than your Baserow, you need to make sure CORS is configured correctly.
 | BASEROW\_PLUGIN\_URLS                          | A comma separated list of plugin urls to install on startup.                                                                             |                                                                               |
 | BASEROW\_DISABLE\_PLUGIN\_INSTALL\_ON\_STARTUP | When set to any non-empty values no automatic startup check and/or install of plugins will be run. Disables the above two env variables. |
 | BASEROW\_PLUGIN\_DIR                           | **INTERNAL** Sets the folder where the Baserow plugin scripts look for plugins.                                                          | In the all-in-one image `/baserow/data/plugins`, otherwise `/baserow/plugins` |
+
+### Posthog configuration
+
+| Name                       | Description                                                     | Defaults                                                                      |
+|----------------------------|-----------------------------------------------------------------|-------------------------------------------------------------------------------|
+| POSTHOG\_PROJECT\_API\_KEY | Set this to your Posthog project API key for product analytics. |                                                                               |
+| POSTHOG\_HOST              | Set this to your Posthog host for product analytics.            |                                                                               |
diff --git a/web-frontend/modules/database/components/airtable/ImportFromAirtable.vue b/web-frontend/modules/database/components/airtable/ImportFromAirtable.vue
index 3fdb4094b..27ca781b5 100644
--- a/web-frontend/modules/database/components/airtable/ImportFromAirtable.vue
+++ b/web-frontend/modules/database/components/airtable/ImportFromAirtable.vue
@@ -149,7 +149,7 @@ export default {
   validations: {
     airtableUrl: {
       valid(value) {
-        const regex = /https:\/\/airtable.com\/shr(.*)$/g
+        const regex = /https:\/\/airtable.com\/[shr|app](.*)$/g
         return !!value.match(regex)
       },
     },