diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md index 03cfad7915..ea0c92fe13 100644 --- a/.serena/memories/audit-log.md +++ b/.serena/memories/audit-log.md @@ -254,7 +254,7 @@ Result: crash reports only exist if the frontend collector runs and `push-audit- Telemetry reuses the same table with **anonymized shadow rows** (`source LIKE 'telemetry:%'`): day-truncated times, `0.0.0.0` IPs, props reduced to uuid/boolean/number values plus a few allowed fields (`lang`, `auth-backend`, derived `email-domain` — never raw emails), and a minimal context allowlist. Full + shadow rows can exist together per event when both flags are on; that duplication is on purpose. -Filters (`filter-telemetry-props` / `filter-telemetry-context`): by default only uuid/boolean/number with a simple key; exceptions: login/register/update-profile and frontend `identify` keep `lang`/`auth-backend`/`email-domain`; `organization-sso-auth-failed` keeps `failure-reason` if it is a known one; `instance-start` passes whole; frontend `navigate` keeps `[:route :file-id :team-id :page-id]`. Free text drops from telemetry but **stays in the full row and in Nexus**. +Filters (`filter-telemetry-props` / `filter-telemetry-context`): by default only uuid/boolean/number with a simple key; exceptions: login/register/update-profile and frontend `identify` keep `lang`/`auth-backend`/`email-domain`; `organization-sso-auth-failed` keeps `failure-reason` if it is a known one; `instance-start` passes whole; frontend `navigate` keeps `[:route :file-id :team-id :page-id]`. Subscription actions have event-specific contracts: backend and frontend `create-subscription` / `create-trial-subscription` preserve the default uuid/boolean/number props plus `type`, `deployment`, `quantity` and `subscription-mode`; backend `update-subscription` preserves only `type`, `deployment`, `previous-status`, `status`, `previous-cancel-at-period-end`, `cancel-at-period-end`, `previous-cancel-at`, `cancel-at` and `changed-fields`; backend `subscription-summary` preserves only `type`, `deployment`, `instance-id`, `snapshot-date`, the per-status `*-count` values, `total-count`, `sync-succeeded`, `sync-failed` and `snapshot-complete`. These subscription exceptions apply only to action events. Other free text drops from telemetry but **stays in the full row and in Nexus**. Shipping (`backend/src/app/tasks/telemetry.clj`): the cron sends shadows to `:telemetry-uri` as JSON in 10k batches and **deletes** them on success; it purges leftovers older than 7 days. It also sends a legacy report (counters, teams, email domains...). On official hosts nothing is collected or sent (`telemetry-excluded?` covers `penpot.app`/`penpot.dev`). diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 430d369343..9a8872ff8e 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -48,6 +48,46 @@ "incomplete-user-info" "unexpected-error"}) +(def ^:private subscription-creation-events + #{"create-subscription" "create-trial-subscription"}) + +(def ^:private subscription-creation-telemetry-props + #{:type + :deployment + :quantity + :subscription-mode}) + +(def ^:private subscription-update-telemetry-props + #{:type + :deployment + :previous-status + :status + :previous-cancel-at-period-end + :cancel-at-period-end + :previous-cancel-at + :cancel-at + :changed-fields}) + +(def ^:private subscription-summary-telemetry-props + #{:type + :deployment + :instance-id + :snapshot-date + :trialing-count + :active-count + :active-cancel-scheduled-count + :past-due-count + :canceled-count + :unpaid-count + :incomplete-count + :incomplete-expired-count + :paused-count + :other-count + :total-count + :sync-succeeded + :sync-failed + :snapshot-complete}) + (def ^:private safe-backend-context-keys #{:version :initiator @@ -348,6 +388,24 @@ (defn filter-telemetry-props [{:keys [source name props type] :as params}] (cond + (and (= type "action") + (contains? subscription-creation-events name) + (or (= source "backend") + (= source "frontend"))) + (let [props' (into {} xf:filter-telemetry-props props) + props' (merge props' (select-keys props subscription-creation-telemetry-props))] + (assoc params :props props')) + + (and (= source "backend") + (= type "action") + (= name "update-subscription")) + (assoc params :props (select-keys props subscription-update-telemetry-props)) + + (and (= source "backend") + (= type "action") + (= name "subscription-summary")) + (assoc params :props (select-keys props subscription-summary-telemetry-props)) + (and (= source "backend") (= name "organization-sso-auth-failed")) (let [props' (into {} xf:filter-telemetry-props props) diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index 2bbdd7c371..b7b9d0c2ed 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -828,6 +828,79 @@ (t/is (not (contains? (:props result) :active))) (t/is (not (contains? (:props result) :label))))) +(t/deftest test-filter-telemetry-props-subscription-creation-keeps-safe-fields + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + organization-id (uuid/next) + props {:type "nitrate:enterprise" + :deployment "saas" + :quantity 5 + :subscription-mode "paid" + :organization-id organization-id + :email "owner@example.com" + :unsafe-label "should-be-stripped"} + expected {:type "nitrate:enterprise" + :deployment "saas" + :quantity 5 + :subscription-mode "paid" + :organization-id organization-id}] + (doseq [source ["backend" "frontend"] + name ["create-subscription" "create-trial-subscription"]] + (t/testing (str source " " name) + (let [result (ftp {:source source + :name name + :type "action" + :props props})] + (t/is (= expected (:props result)))))))) + +(t/deftest test-filter-telemetry-props-subscription-update-keeps-safe-fields + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + props {:type "nitrate:enterprise" + :deployment "selfhost" + :previous-status "trialing" + :status "active" + :previous-cancel-at-period-end false + :cancel-at-period-end true + :previous-cancel-at nil + :cancel-at "2026-12-01T00:00:00.000Z" + :changed-fields ["status" "cancel-at-period-end" "cancel-at"] + :email "owner@example.com" + :unsafe-label "should-be-stripped"} + result (ftp {:source "backend" + :name "update-subscription" + :type "action" + :props props})] + (t/is (= (dissoc props :email :unsafe-label) + (:props result))))) + +(t/deftest test-filter-telemetry-props-subscription-summary-keeps-safe-fields + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + props {:type "nitrate:enterprise" + :deployment "saas" + :instance-id "00000000-0000-4000-8000-000000000099" + :snapshot-date "2026-11-05" + :trialing-count 1 + :active-count 2 + :active-cancel-scheduled-count 3 + :past-due-count 4 + :canceled-count 5 + :unpaid-count 6 + :incomplete-count 7 + :incomplete-expired-count 8 + :paused-count 9 + :other-count 10 + :total-count 55 + :sync-succeeded 54 + :sync-failed 1 + :snapshot-complete false + :email "owner@example.com" + :unsafe-label "should-be-stripped"} + result (ftp {:source "backend" + :name "subscription-summary" + :type "action" + :props props})] + (t/is (= (dissoc props :email :unsafe-label) + (:props result))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SEND-EVENT-BATCH PAYLOAD STRUCTURE ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;