mirror of
https://github.com/penpot/penpot.git
synced 2026-05-13 03:53:51 +00:00
* 🎉 Add telemetry anonymous event collection Rewrite the audit logging subsystem to support three operating modes and add anonymous telemetry event collection: Modes: - A (audit-log only): events persisted with full context - B (audit-log + telemetry): same as A, plus events are collected for telemetry shipping - C (telemetry-only): events stored anonymously with PII stripped, telemetry flag active, audit-log flag inactive Audit system refactoring (app.loggers.audit): - Replace qualified map keys (::audit/name etc.) with plain keywords - Rename submit! -> submit, insert! -> insert, prepare-event -> prepare-rpc-event - Add submit* as a lower-level public API - Add process-event dispatch function that handles all three modes and webhooks in a single tx-run! - Add :id to event schema (auto-generated if omitted) - Add filter-telemetry-props: anonymises event props per event type. Keeps UUID/boolean/number values; for login/identify events preserves lang, auth-backend, email-domain; for navigate events preserves route, file-id, team-id, page-id; instance-start trigger passes through. - Add filter-telemetry-context: retains only safe context keys. Backend: version, initiator, client-version, client-user-agent. Frontend: browser, os, locale, screen metrics, event-origin. - Timestamps truncated to day precision via ct/truncate for telemetry storage - PII stripped: props emptied, ip-addr zeroed, session-linking and access-token fields removed from context Config (app.config): - Derive :enable-telemetry flag from telemetry-enabled config option Email utilities (app.email): - Add email/clean and email/get-domain helper functions for domain extraction from email addresses Setup (app.setup): - Emit instance-start trigger event at system startup - Simplify handle-instance-id (remove read-only check) RPC layer (app.rpc): - wrap-audit now activates when :telemetry flag is set - Add :request-id to RPC params context for event correlation RPC commands (management, teams_invitations, verify_token, OIDC auth, webhooks): migrate all audit call sites to use the new plain-key API SREPL (app.srepl.main): - Migrate all audit/insert! calls to audit/insert with plain keys Telemetry task (app.tasks.telemetry): - Restructure legacy report into make-legacy-request; distinguish payload type as :telemetry-legacy-report - Add collect-and-send-audit-events: loop fetching up to 10,000 rows per iteration, encodes and sends each page, deletes on success, stops immediately on failure for retry - Add send-event-batch: POSTs fressian+zstd batch (base64 via blob/encode-str) to the telemetry endpoint with instance-id per event - Add gc-telemetry-events: enforces 100,000-row safety cap by dropping oldest rows first - Add delete-sent-events: deletes successfully shipped rows by id Blob utilities (app.util.blob): - Add encode-str/decode-str: combine fressian+zstd encoding with URL- safe base64 for JSON-safe string transport Database: - Add migration 0145: index on audit_log (source, created_at ASC) for efficient telemetry batch collection queries Frontend: - Always initialize event system regardless of :audit-log flag - Defer auth events (signin identify) to after profile is set - Refactor event subsystem for telemetry support Tests (21 test vars, 94 assertions in tasks-telemetry-test): - Cover all code paths: disabled/enabled telemetry, no-events no-op, happy-path batch send and delete, failure retention, payload anonymity, context stripping, timestamp day precision, batch encoding round-trip, multi-page iteration, GC cap enforcement, partial failure handling - blob encode-str/decode-str round-trip tests (14 test vars) - RPC audit integration tests (5 test vars) Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📎 Add pr feedback changes --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz>
911 lines
41 KiB
Clojure
911 lines
41 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC
|
|
|
|
(ns backend-tests.tasks-telemetry-test
|
|
(:require
|
|
[app.common.time :as ct]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.loggers.audit :as audit]
|
|
[app.tasks.telemetry :as telemetry]
|
|
[app.util.blob :as blob]
|
|
[app.util.json :as json]
|
|
[backend-tests.helpers :as th]
|
|
[clojure.test :as t]
|
|
[mockery.core :refer [with-mocks]]
|
|
[promesa.exec :as px]))
|
|
|
|
(t/use-fixtures :once th/state-init)
|
|
|
|
;; Mock px/sleep for all tests to avoid 10s random delays.
|
|
;; Composed with database-reset so both apply.
|
|
(defn- test-fixture [next]
|
|
(th/database-reset
|
|
(fn []
|
|
(with-redefs [px/sleep (constantly nil)]
|
|
(next)))))
|
|
|
|
(t/use-fixtures :each test-fixture)
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; HELPERS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- insert-telemetry-row!
|
|
"Insert a single anonymised audit_log row as the telemetry mode does."
|
|
([name] (insert-telemetry-row! name {}))
|
|
([name {:keys [tracked-at created-at source]
|
|
:or {tracked-at (ct/now)
|
|
created-at (ct/now)
|
|
source "telemetry:backend"}}]
|
|
(th/db-insert! :audit-log
|
|
{:id (uuid/next)
|
|
:name name
|
|
:type "action"
|
|
:source source
|
|
:profile-id uuid/zero
|
|
:ip-addr (db/inet "0.0.0.0")
|
|
:props (db/tjson {})
|
|
:context (db/tjson {})
|
|
:tracked-at tracked-at
|
|
:created-at created-at})))
|
|
|
|
(defn- count-telemetry-rows []
|
|
(-> (th/db-exec-one! ["SELECT count(*) AS cnt FROM audit_log WHERE source IN ('telemetry:backend', 'telemetry:frontend')"])
|
|
:cnt
|
|
long))
|
|
|
|
(defn- decode-event-batch
|
|
"Decode the base64+fressian+zstd event-batch sent to the mock."
|
|
[b64-str]
|
|
(blob/decode-str b64-str))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; STATS / REPORT STRUCTURE TESTS (existing behaviour, extended)
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-base-report-data-structure
|
|
(with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(let [prof (th/create-profile* 1 {:is-active true
|
|
:props {:newsletter-updates true}})]
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
(t/is (:called? @mock))
|
|
(let [[_ data] (-> @mock :call-args)]
|
|
(t/is (= :telemetry-legacy-report (:type data)))
|
|
(t/is (contains? data :subscriptions))
|
|
(t/is (= [(:email prof)] (:subscriptions data)))
|
|
(t/is (contains? data :stats))
|
|
(let [stats (:stats data)]
|
|
(t/is (contains? stats :total-fonts))
|
|
(t/is (contains? stats :total-users))
|
|
(t/is (contains? stats :total-projects))
|
|
(t/is (contains? stats :total-files))
|
|
(t/is (contains? stats :total-teams))
|
|
(t/is (contains? stats :total-comments))
|
|
(t/is (contains? stats :jvm-cpus))
|
|
(t/is (contains? stats :jvm-heap-max))
|
|
(t/is (contains? stats :max-users-on-team))
|
|
(t/is (contains? stats :avg-users-on-team))
|
|
(t/is (contains? stats :max-files-on-project))
|
|
(t/is (contains? stats :avg-files-on-project))
|
|
(t/is (contains? stats :max-projects-on-team))
|
|
(t/is (contains? stats :avg-files-on-project))
|
|
(t/is (contains? stats :email-domains))
|
|
(t/is (= ["nodomain.com"] (:email-domains stats)))
|
|
;; public-uri must be a string
|
|
(t/is (string? (:public-uri stats)))
|
|
(t/is (not-empty (:public-uri stats))))
|
|
(t/is (contains? data :version))
|
|
(t/is (contains? data :instance-id))))))
|
|
|
|
(t/deftest test-telemetry-disabled-no-send
|
|
;; When telemetry is disabled and no newsletter subscriptions exist,
|
|
;; make-legacy-request must not be called at all.
|
|
(with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(with-redefs [cf/flags #{}]
|
|
(th/create-profile* 1 {:is-active true})
|
|
(th/run-task! :telemetry {:send? true})
|
|
(t/is (not (:called? @mock))))))
|
|
|
|
(t/deftest test-telemetry-disabled-newsletter-only-send
|
|
;; When telemetry is disabled but a user has newsletter-updates opted in,
|
|
;; make-legacy-request is called once with only subscriptions + version (no stats).
|
|
(with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(with-redefs [cf/flags #{}]
|
|
(let [prof (th/create-profile* 1 {:is-active true
|
|
:props {:newsletter-updates true}})]
|
|
(th/run-task! :telemetry {:send? true})
|
|
(t/is (:called? @mock))
|
|
(let [[_ data] (:call-args @mock)]
|
|
;; Limited payload — no stats
|
|
(t/is (contains? data :subscriptions))
|
|
(t/is (contains? data :version))
|
|
(t/is (not (contains? data :stats)))
|
|
(t/is (= [(:email prof)] (:subscriptions data))))))))
|
|
|
|
(t/deftest test-send-is-skipped-when-send?-false
|
|
;; Passing send?=false must suppress all HTTP calls even when enabled.
|
|
(with-mocks [mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(th/create-profile* 1 {:is-active true})
|
|
(th/run-task! :telemetry {:send? false :enabled? true})
|
|
(t/is (not (:called? @mock))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; AUDIT-EVENT BATCH COLLECTION TESTS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-no-audit-events-no-batch-call
|
|
;; When telemetry is enabled but there are no audit_log rows with
|
|
;; source='telemetry', the batch send path must not be invoked.
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}
|
|
batch-mock {:target 'app.tasks.telemetry/send-event-batch
|
|
:return true}]
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
(t/is (:called? @legacy-mock))
|
|
(t/is (not (:called? @batch-mock))))))
|
|
|
|
(t/deftest test-audit-events-sent-and-deleted-on-success
|
|
;; Happy path: telemetry rows are collected, shipped as a batch and
|
|
;; deleted from the table when the endpoint returns success.
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}
|
|
batch-mock {:target 'app.tasks.telemetry/send-event-batch
|
|
:return true}]
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(insert-telemetry-row! "navigate")
|
|
(insert-telemetry-row! "create-file")
|
|
(insert-telemetry-row! "update-file")
|
|
|
|
(t/is (= 3 (count-telemetry-rows)))
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
;; batch send was called at least once
|
|
(t/is (:called? @batch-mock))
|
|
|
|
;; all rows deleted after successful send
|
|
(t/is (= 0 (count-telemetry-rows))))))
|
|
|
|
(t/deftest test-audit-events-kept-on-batch-failure
|
|
;; When the batch endpoint returns failure the rows must be retained
|
|
;; so the next scheduled run can retry.
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}
|
|
batch-mock {:target 'app.tasks.telemetry/send-event-batch
|
|
:return false}]
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(insert-telemetry-row! "navigate")
|
|
(insert-telemetry-row! "create-file")
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
(t/is (:called? @batch-mock))
|
|
;; rows still present — not deleted on failure
|
|
(t/is (= 2 (count-telemetry-rows))))))
|
|
|
|
(t/deftest test-audit-events-not-collected-when-audit-log-flag-set
|
|
;; When the :audit-log flag is active, mode C is disabled and the
|
|
;; batch path must never run (audit-log owns those rows instead).
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}
|
|
batch-mock {:target 'app.tasks.telemetry/send-event-batch
|
|
:return true}]
|
|
(with-redefs [cf/flags #{:telemetry :audit-log}]
|
|
(insert-telemetry-row! "navigate")
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
(t/is (not (:called? @batch-mock)))
|
|
;; row untouched
|
|
(t/is (= 1 (count-telemetry-rows))))))
|
|
|
|
(t/deftest test-batch-payload-contains-required-fields
|
|
;; Inspect the actual arguments forwarded to send-event-batch to
|
|
;; verify the payload carries instance-id, version and events.
|
|
(let [captured (atom nil)]
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(with-redefs [cf/flags #{:telemetry}
|
|
telemetry/send-event-batch
|
|
(fn [_cfg batch]
|
|
(reset! captured batch)
|
|
true)]
|
|
(insert-telemetry-row! "navigate")
|
|
(insert-telemetry-row! "create-file")
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
(t/is (some? @captured))
|
|
(let [batch @captured]
|
|
;; batch is a seq of event maps
|
|
(t/is (seq batch))
|
|
(t/is (= 2 (count batch)))
|
|
;; each event has name, type, source — profile-id is preserved,
|
|
;; props and ip-addr are stripped
|
|
(let [ev (first batch)]
|
|
(t/is (contains? ev :name))
|
|
(t/is (contains? ev :type))
|
|
(t/is (contains? ev :source))
|
|
(t/is (contains? ev :profile-id))
|
|
;; props are present but empty (stripped at ingest time)
|
|
(t/is (= {} (:props ev)))
|
|
(t/is (not (contains? ev :ip-addr)))))))))
|
|
|
|
(t/deftest test-batch-encoding-is-decodable
|
|
;; Verify that encode-batch produces a blob that round-trips back
|
|
;; through blob/decode to the original data.
|
|
(let [events [{:name "navigate" :type "action" :source "telemetry"
|
|
:tracked-at (ct/now)}
|
|
{:name "create-file" :type "action" :source "telemetry"
|
|
:tracked-at (ct/now)}]
|
|
;; Call the private fn through the ns-mapped var
|
|
encode (ns-resolve 'app.tasks.telemetry 'encode-batch)
|
|
encoded (encode events)
|
|
decoded (decode-event-batch encoded)]
|
|
(t/is (string? encoded))
|
|
(t/is (seq decoded))
|
|
(t/is (= (count events) (count decoded)))
|
|
(t/is (= "navigate" (:name (first decoded))))
|
|
(t/is (= "create-file" (:name (second decoded))))))
|
|
|
|
(t/deftest test-multiple-batches-when-many-events
|
|
;; Lower batch-size to 1 so that 3 events produce 3 separate
|
|
;; HTTP requests and verify all are sent and all rows deleted.
|
|
(let [call-count (atom 0)]
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(with-redefs [cf/flags #{:telemetry}
|
|
telemetry/batch-size 1
|
|
telemetry/send-event-batch
|
|
(fn [_cfg _batch]
|
|
(swap! call-count inc)
|
|
true)]
|
|
(insert-telemetry-row! "navigate")
|
|
(insert-telemetry-row! "create-file")
|
|
(insert-telemetry-row! "update-file")
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
;; Each event is fetched and sent in its own loop iteration
|
|
(t/is (= 3 @call-count))
|
|
;; All rows deleted after all iterations succeed
|
|
(t/is (= 0 (count-telemetry-rows)))))))
|
|
|
|
(t/deftest test-partial-failure-stops-remaining-batches
|
|
;; With batch-size 1, when the second send fails the loop stops.
|
|
;; The first batch was already deleted; the two remaining rows
|
|
;; are retained for the next run.
|
|
(let [call-count (atom 0)]
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(with-redefs [cf/flags #{:telemetry}
|
|
telemetry/batch-size 1
|
|
telemetry/send-event-batch
|
|
(fn [_cfg _batch]
|
|
(swap! call-count inc)
|
|
;; fail on the second call
|
|
(not= 2 @call-count))]
|
|
(insert-telemetry-row! "navigate")
|
|
(insert-telemetry-row! "create-file")
|
|
(insert-telemetry-row! "update-file")
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
;; Stopped at iteration 2 — third event never attempted
|
|
(t/is (= 2 @call-count))
|
|
;; First batch was deleted on success; 2 rows remain for retry
|
|
(t/is (= 2 (count-telemetry-rows)))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; GC / RETENTION-WINDOW TESTS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-gc-purges-events-older-than-7-days
|
|
;; Insert events from 8 days ago (stale) and from today (fresh).
|
|
;; After the task runs, stale events must be purged by GC and fresh
|
|
;; ones shipped by the batch sender.
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}
|
|
batch-mock {:target 'app.tasks.telemetry/send-event-batch
|
|
:return true}]
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(let [now (ct/now)
|
|
eight-days (ct/minus now (ct/duration {:days 8}))]
|
|
;; Stale events (older than 7 days)
|
|
(insert-telemetry-row! "stale-1" {:created-at eight-days :tracked-at eight-days})
|
|
(insert-telemetry-row! "stale-2" {:created-at eight-days :tracked-at eight-days})
|
|
;; Fresh events (today)
|
|
(insert-telemetry-row! "fresh-1" {:created-at now :tracked-at now})
|
|
(insert-telemetry-row! "fresh-2" {:created-at now :tracked-at now})
|
|
|
|
(t/is (= 4 (count-telemetry-rows)))
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
;; GC purged the 2 stale rows, batch sender shipped the 2 fresh ones
|
|
(t/is (= 0 (count-telemetry-rows)))))))
|
|
|
|
(t/deftest test-gc-keeps-events-within-7-day-window
|
|
;; When all events are within the 7-day window, GC must not delete
|
|
;; anything and all rows are forwarded to the batch sender.
|
|
(let [batch-events (atom nil)]
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(with-redefs [cf/flags #{:telemetry}
|
|
telemetry/send-event-batch
|
|
(fn [_cfg batch]
|
|
(reset! batch-events batch)
|
|
true)]
|
|
(let [six-days-ago (ct/minus (ct/now) (ct/duration {:days 6}))]
|
|
(insert-telemetry-row! "recent-1" {:created-at six-days-ago :tracked-at six-days-ago})
|
|
(insert-telemetry-row! "recent-2" {:created-at six-days-ago :tracked-at six-days-ago}))
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
;; Both events forwarded — GC left them alone
|
|
(t/is (= 2 (count @batch-events)))
|
|
(t/is (= 0 (count-telemetry-rows)))))))
|
|
|
|
(t/deftest test-gc-deletes-only-stale-events
|
|
;; Insert a mix of stale (8 days old) and fresh (1 day old) events.
|
|
;; After GC, only fresh events should remain for the batch sender.
|
|
(let [batch-events (atom nil)]
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(with-redefs [cf/flags #{:telemetry}
|
|
telemetry/send-event-batch
|
|
(fn [_cfg batch]
|
|
(reset! batch-events batch)
|
|
true)]
|
|
(let [eight-days (ct/minus (ct/now) (ct/duration {:days 8}))
|
|
one-day (ct/minus (ct/now) (ct/duration {:days 1}))]
|
|
(insert-telemetry-row! "stale" {:created-at eight-days :tracked-at eight-days})
|
|
(insert-telemetry-row! "fresh" {:created-at one-day :tracked-at one-day}))
|
|
|
|
(t/is (= 2 (count-telemetry-rows)))
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
;; GC purged stale, batch shipped fresh
|
|
(t/is (= 1 (count @batch-events)))
|
|
(t/is (= "fresh" (:name (first @batch-events))))
|
|
(t/is (= 0 (count-telemetry-rows)))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; ANONYMITY TESTS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-telemetry-rows-stored-without-pii
|
|
;; Rows written to audit_log in telemetry mode must carry no PII:
|
|
;; empty props, zeroed ip, profile-id=zero, source='telemetry'.
|
|
;; Safe context fields (browser, os, version, etc.) are preserved
|
|
;; but session-linking and access-token fields are stripped.
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(let [_prof (th/create-profile* 1 {:is-active true})
|
|
safe-ctx {:browser "Chrome"
|
|
:browser-version "120.0"
|
|
:os "Linux"
|
|
:version "2.0.0"}]
|
|
;; Simulate what app.loggers.audit/process-event does in mode C
|
|
(th/db-insert! :audit-log
|
|
{:id (uuid/next)
|
|
:name "create-project"
|
|
:type "action"
|
|
:source "telemetry:backend"
|
|
:profile-id uuid/zero
|
|
:ip-addr (db/inet "0.0.0.0")
|
|
:props (db/tjson {})
|
|
:context (db/tjson safe-ctx)
|
|
:tracked-at (ct/now)
|
|
:created-at (ct/now)})
|
|
|
|
(let [[row] (th/db-exec! ["SELECT * FROM audit_log WHERE source = 'telemetry:backend'"])]
|
|
(t/is (= "telemetry:backend" (:source row)))
|
|
;; props are always empty
|
|
(t/is (= "{}" (str (:props row))))
|
|
;; ip_addr is the sentinel zero address
|
|
(t/is (= "0.0.0.0" (str (:ip-addr row))))
|
|
;; profile-id is uuid/zero — not a real user id
|
|
(t/is (= uuid/zero (:profile-id row)))))))
|
|
|
|
(t/deftest test-batch-events-contain-no-pii-fields
|
|
;; The event maps forwarded to send-event-batch must not carry props,
|
|
;; ip-addr or profile-id. Safe context fields (browser, os, etc.) may
|
|
;; be present but session-linking keys must be absent.
|
|
(let [captured-batch (atom nil)
|
|
;; Insert a row that carries safe context (as the real path does)
|
|
safe-ctx {:browser "Firefox" :browser-version "121.0"
|
|
:os "macOS" :session "should-be-stripped"
|
|
:external-session-id "also-stripped"}]
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(with-redefs [cf/flags #{:telemetry}
|
|
telemetry/send-event-batch
|
|
(fn [_cfg batch]
|
|
(reset! captured-batch batch)
|
|
true)]
|
|
;; Insert with safe context already pre-filtered (as the ingest path does)
|
|
(th/db-insert! :audit-log
|
|
{:id (uuid/next)
|
|
:name "navigate"
|
|
:type "action"
|
|
:source "telemetry:frontend"
|
|
:profile-id uuid/zero
|
|
:ip-addr (db/inet "0.0.0.0")
|
|
:props (db/tjson {})
|
|
:context (db/tjson (dissoc safe-ctx :session :external-session-id))
|
|
:tracked-at (ct/now)
|
|
:created-at (ct/now)})
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
(t/is (= 1 (count @captured-batch)))
|
|
(let [ev (first @captured-batch)]
|
|
;; must have the core identity fields including profile-id
|
|
(t/is (contains? ev :name))
|
|
(t/is (contains? ev :type))
|
|
(t/is (contains? ev :source))
|
|
(t/is (contains? ev :tracked-at))
|
|
(t/is (contains? ev :profile-id))
|
|
;; props are present but empty (stripped at ingest time)
|
|
(t/is (= {} (:props ev)))
|
|
;; ip-addr is stripped
|
|
(t/is (not (contains? ev :ip-addr)))
|
|
;; context may be present and must not contain session-linking keys
|
|
(when-let [ctx (:context ev)]
|
|
(t/is (not (contains? ctx :session)))
|
|
(t/is (not (contains? ctx :external-session-id)))
|
|
;; safe keys should be present
|
|
(t/is (contains? ctx :browser))))))))
|
|
|
|
(t/deftest test-telemetry-rows-have-day-precision-timestamps
|
|
;; Telemetry events must be stored with timestamps truncated to day
|
|
;; precision so that exact event timing cannot be inferred.
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(let [process-event (ns-resolve 'app.loggers.audit 'process-event)
|
|
profile (th/create-profile* 1 {:is-active true})
|
|
tnow (ct/now)
|
|
event {:type "action"
|
|
:name "create-project"
|
|
:profile-id (:id profile)
|
|
:source "backend"
|
|
:props {}
|
|
:context {}
|
|
:created-at tnow
|
|
:tracked-at tnow
|
|
:ip-addr "0.0.0.0"}]
|
|
(db/tx-run! th/*system* process-event event)
|
|
(let [[row] (th/db-exec! ["SELECT * FROM audit_log WHERE source = 'telemetry:backend'"])]
|
|
(t/is (some? row))
|
|
(let [created-at (:created-at row)
|
|
tracked-at (:tracked-at row)
|
|
day-now (ct/truncate (ct/now) :days)]
|
|
;; Both timestamps must equal midnight of the current day
|
|
(t/is (= day-now created-at))
|
|
(t/is (= day-now tracked-at)))))))
|
|
|
|
(t/deftest test-backend-ingest-full-row-shape
|
|
;; Verify the full row shape stored by process-event in telemetry mode:
|
|
;; source=telemetry:backend, empty props, zeroed ip, context filtered to safe
|
|
;; backend keys only, profile-id preserved, timestamps truncated.
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(let [process-event (ns-resolve 'app.loggers.audit 'process-event)
|
|
profile (th/create-profile* 1 {:is-active true})
|
|
tnow (ct/now)
|
|
event {:type "action"
|
|
:name "create-project"
|
|
:profile-id (:id profile)
|
|
:source "backend"
|
|
:context {:initiator "app"
|
|
:version "2.0.0"
|
|
:client-version "1.0"
|
|
:client-user-agent "Mozilla/5.0"
|
|
:external-session-id "should-be-stripped"
|
|
:session "also-stripped"}
|
|
:props {:some-prop "value"}
|
|
:created-at tnow
|
|
:tracked-at tnow
|
|
:ip-addr "0.0.0.0"}]
|
|
(db/tx-run! th/*system* process-event event)
|
|
|
|
(let [[row] (th/db-exec! ["SELECT * FROM audit_log WHERE source = 'telemetry:backend'"])]
|
|
(t/is (some? row))
|
|
;; source
|
|
(t/is (= "telemetry:backend" (:source row)))
|
|
;; profile-id preserved
|
|
(t/is (= (:id profile) (:profile-id row)))
|
|
;; name
|
|
(t/is (= "create-project" (:name row)))
|
|
;; type
|
|
(t/is (= "action" (:type row)))
|
|
;; props stripped to empty
|
|
(t/is (= "{}" (str (:props row))))
|
|
;; ip zeroed
|
|
(t/is (= "0.0.0.0" (str (:ip-addr row))))
|
|
;; timestamps truncated to day
|
|
(let [day-now (ct/truncate (ct/now) :days)]
|
|
(t/is (= day-now (:created-at row)))
|
|
(t/is (= day-now (:tracked-at row))))
|
|
;; context filtered: only safe backend keys retained
|
|
(let [ctx (db/decode-transit-pgobject (:context row))]
|
|
(t/is (= "app" (:initiator ctx)))
|
|
(t/is (= "2.0.0" (:version ctx)))
|
|
(t/is (= "1.0" (:client-version ctx)))
|
|
(t/is (= "Mozilla/5.0" (:client-user-agent ctx)))
|
|
;; session-linking keys stripped
|
|
(t/is (not (contains? ctx :external-session-id)))
|
|
(t/is (not (contains? ctx :session))))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; FILTER-TELEMETRY-CONTEXT UNIT TESTS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-filter-telemetry-context-keeps-browser-fields
|
|
;; Safe environment fields must survive the filter.
|
|
(let [filter-telemetry-context (ns-resolve 'app.loggers.audit 'filter-telemetry-context)
|
|
ctx {:browser "Chrome"
|
|
:browser-version "120.0"
|
|
:engine "Blink"
|
|
:engine-version "120.0"
|
|
:os "Windows 11"
|
|
:os-version "11"
|
|
:device-type "unknown"
|
|
:device-arch "amd64"
|
|
:locale "en-US"
|
|
:version "2.0.0"
|
|
:screen-width 1920
|
|
:screen-height 1080
|
|
:event-origin "workspace"}
|
|
result (:context (filter-telemetry-context {:source "frontend" :context ctx}))]
|
|
(t/is (= "Chrome" (:browser result)))
|
|
(t/is (= "120.0" (:browser-version result)))
|
|
(t/is (= "Windows 11" (:os result)))
|
|
(t/is (= "en-US" (:locale result)))
|
|
(t/is (= "workspace" (:event-origin result)))
|
|
(t/is (= 1920 (:screen-width result)))))
|
|
|
|
(t/deftest test-filter-telemetry-context-strips-pii-keys
|
|
;; Session-linking and access-token fields must be removed.
|
|
(let [filter-telemetry-context (ns-resolve 'app.loggers.audit 'filter-telemetry-context)
|
|
ctx {:browser "Firefox"
|
|
:session "abc-session-id"
|
|
:external-session-id "ext-123"
|
|
:file-stats {:total-shapes 42}
|
|
:initiator "app"
|
|
:access-token-id "tok-456"
|
|
:access-token-type "api-key"}
|
|
result (:context (filter-telemetry-context {:source "frontend" :context ctx}))]
|
|
(t/is (= "Firefox" (:browser result)))
|
|
(t/is (not (contains? result :session)))
|
|
(t/is (not (contains? result :external-session-id)))
|
|
(t/is (not (contains? result :file-stats)))
|
|
(t/is (not (contains? result :initiator)))
|
|
(t/is (not (contains? result :access-token-id)))
|
|
(t/is (not (contains? result :access-token-type)))))
|
|
|
|
(t/deftest test-filter-telemetry-context-empty-input
|
|
;; An empty context should return an empty map without error.
|
|
(let [filter-telemetry-context (ns-resolve 'app.loggers.audit 'filter-telemetry-context)]
|
|
(t/is (= {} (:context (filter-telemetry-context {:source "frontend" :context {}}))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; FILTER-TELEMETRY-PROPS UNIT TESTS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-filter-telemetry-props-login-event-keeps-safe-profile-fields
|
|
;; Login/register/update events carry safe profile-derived fields:
|
|
;; :lang, :auth-backend, :email-domain. Raw :email is stripped.
|
|
(let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)]
|
|
;; backend login-with-password
|
|
(let [result (ftp {:source "backend"
|
|
:name "login-with-password"
|
|
:type "action"
|
|
:props {:email "user@example.com"
|
|
:fullname "John Doe"
|
|
:lang "en"
|
|
:auth-backend "password"
|
|
:id (uuid/next)}})]
|
|
(t/is (= "en" (get-in result [:props :lang])))
|
|
(t/is (= "password" (get-in result [:props :auth-backend])))
|
|
(t/is (= "example.com" (get-in result [:props :email-domain])))
|
|
;; Raw email and fullname are stripped
|
|
(t/is (not (contains? (:props result) :email)))
|
|
(t/is (not (contains? (:props result) :fullname)))
|
|
;; UUID values survive the xf:filter-telemetry-props filter
|
|
(t/is (some? (get-in result [:props :id]))))
|
|
|
|
;; backend register-profile
|
|
(let [result (ftp {:source "backend"
|
|
:name "register-profile"
|
|
:type "action"
|
|
:props {:email "new@corp.org"
|
|
:lang "es"
|
|
:auth-backend "oidc"}})]
|
|
(t/is (= "es" (get-in result [:props :lang])))
|
|
(t/is (= "oidc" (get-in result [:props :auth-backend])))
|
|
(t/is (= "corp.org" (get-in result [:props :email-domain]))))
|
|
|
|
;; backend login-with-oidc
|
|
(let [result (ftp {:source "backend"
|
|
:name "login-with-oidc"
|
|
:type "action"
|
|
:props {:email "u@corp.io" :lang "fr" :auth-backend "oidc"}})]
|
|
(t/is (= "fr" (get-in result [:props :lang])))
|
|
(t/is (= "oidc" (get-in result [:props :auth-backend])))
|
|
(t/is (= "corp.io" (get-in result [:props :email-domain]))))
|
|
|
|
;; backend update-profile
|
|
(let [result (ftp {:source "backend"
|
|
:name "update-profile"
|
|
:type "action"
|
|
:props {:email "u@corp.io" :lang "de"}})]
|
|
(t/is (= "de" (get-in result [:props :lang])))
|
|
(t/is (= "corp.io" (get-in result [:props :email-domain]))))))
|
|
|
|
(t/deftest test-filter-telemetry-props-frontend-identify-keeps-safe-profile-fields
|
|
;; Frontend identify events also carry safe profile-derived fields.
|
|
(let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)]
|
|
(let [result (ftp {:source "frontend"
|
|
:name "signin"
|
|
:type "identify"
|
|
:props {:email "user@example.com"
|
|
:fullname "Jane Doe"
|
|
:lang "pt"
|
|
:auth-backend "password"
|
|
:some-string "should-be-stripped"}})]
|
|
(t/is (= "pt" (get-in result [:props :lang])))
|
|
(t/is (= "password" (get-in result [:props :auth-backend])))
|
|
(t/is (= "example.com" (get-in result [:props :email-domain])))
|
|
;; PII stripped
|
|
(t/is (not (contains? (:props result) :email)))
|
|
(t/is (not (contains? (:props result) :fullname)))
|
|
;; String values that are not UUID/boolean/number are stripped
|
|
(t/is (not (contains? (:props result) :some-string))))))
|
|
|
|
(t/deftest test-filter-telemetry-props-instance-start-passthrough
|
|
;; instance-start trigger events pass through as-is.
|
|
(let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)
|
|
props {:total-teams 5 :total-users 42 :version "2.0"}
|
|
result (ftp {:source "backend"
|
|
:name "instance-start"
|
|
:type "trigger"
|
|
:props props})]
|
|
(t/is (= props (:props result)))))
|
|
|
|
(t/deftest test-filter-telemetry-props-generic-event-keeps-uuid-boolean-number
|
|
;; Generic events (create-file, etc.) keep only entries
|
|
;; whose values are UUIDs, booleans, or numbers.
|
|
(let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)
|
|
id (uuid/next)
|
|
result (ftp {:source "frontend"
|
|
:name "create-file"
|
|
:type "action"
|
|
:props {:project-id id
|
|
:team-id id
|
|
:route "dashboard-files"
|
|
:count 42
|
|
:active true
|
|
:label "should-be-stripped"}})]
|
|
;; UUIDs survive
|
|
(t/is (= id (get-in result [:props :project-id])))
|
|
(t/is (= id (get-in result [:props :team-id])))
|
|
;; Numbers survive
|
|
(t/is (= 42 (get-in result [:props :count])))
|
|
;; Booleans survive
|
|
(t/is (true? (get-in result [:props :active])))
|
|
;; Strings are stripped
|
|
(t/is (not (contains? (:props result) :route)))
|
|
(t/is (not (contains? (:props result) :label)))))
|
|
|
|
(t/deftest test-filter-telemetry-props-navigate-keeps-route-and-ids
|
|
;; Frontend navigate events keep specific routing keys: :route,
|
|
;; :file-id, :team-id, :page-id. These ids are strings because
|
|
;; routing events don't coerce them. All other props are stripped.
|
|
(let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)
|
|
file-id (str (uuid/next))
|
|
team-id (str (uuid/next))
|
|
page-id (str (uuid/next))
|
|
result (ftp {:source "frontend"
|
|
:name "navigate"
|
|
:type "action"
|
|
:props {:file-id file-id
|
|
:team-id team-id
|
|
:page-id page-id
|
|
:route "dashboard-index"
|
|
:session "abc"
|
|
:count 42
|
|
:active true
|
|
:label "should-be-stripped"}})]
|
|
;; Allowed routing keys survive (as strings, not coerced to UUID)
|
|
(t/is (= file-id (get-in result [:props :file-id])))
|
|
(t/is (= team-id (get-in result [:props :team-id])))
|
|
(t/is (= page-id (get-in result [:props :page-id])))
|
|
(t/is (= "dashboard-index" (get-in result [:props :route])))
|
|
;; Everything else is stripped
|
|
(t/is (not (contains? (:props result) :session)))
|
|
(t/is (not (contains? (:props result) :count)))
|
|
(t/is (not (contains? (:props result) :active)))
|
|
(t/is (not (contains? (:props result) :label)))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; SEND-EVENT-BATCH PAYLOAD STRUCTURE
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-send-event-batch-payload-structure
|
|
;; Verify the HTTP request sent by send-event-batch carries the
|
|
;; correct outer wrapper: :type, :version, :instance-id, :events.
|
|
(let [captured-request (atom nil)]
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}
|
|
http-mock {:target 'app.http.client/req
|
|
:return {:status 200}}]
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(insert-telemetry-row! "navigate")
|
|
(insert-telemetry-row! "create-file")
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
;; http/req was called (by both send-legacy-data and send-event-batch)
|
|
(t/is (:called? @http-mock))
|
|
;; Find the call whose body contains :telemetry-events
|
|
(let [calls (filter (fn [args]
|
|
(let [[_ request] args
|
|
body (:body request)]
|
|
(and (string? body)
|
|
(re-find #"telemetry-events" body))))
|
|
(:call-args-list @http-mock))]
|
|
(t/is (= 1 (count calls)))
|
|
(let [[_ request] (first calls)
|
|
body (json/decode (:body request))]
|
|
;; Outer payload fields
|
|
(t/is (= "telemetry-events" (name (:type body))))
|
|
(t/is (string? (:version body)))
|
|
(t/is (some? (:instance-id body)))
|
|
;; :events is a base64-encoded blob
|
|
(t/is (string? (:events body)))
|
|
(t/is (pos? (count (:events body))))))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; TASK BRANCH COVERAGE
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-enabled-no-subs-no-events-legacy-still-sends
|
|
;; When telemetry is enabled, there are no newsletter subscriptions
|
|
;; and no audit_log rows, the legacy report must still be sent.
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}
|
|
batch-mock {:target 'app.tasks.telemetry/send-event-batch
|
|
:return true}]
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
;; No profiles with newsletter-updates, no telemetry rows
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
;; Legacy report was sent
|
|
(t/is (:called? @legacy-mock))
|
|
(let [[_ data] (:call-args @legacy-mock)]
|
|
(t/is (= :telemetry-legacy-report (:type data)))
|
|
(t/is (contains? data :stats))
|
|
;; No subscriptions in the payload
|
|
(t/is (not (contains? data :subscriptions))))
|
|
|
|
;; No events to batch-send
|
|
(t/is (not (:called? @batch-mock))))))
|
|
|
|
(t/deftest test-legacy-succeeds-batch-fails
|
|
;; The legacy report and event batch are independent paths.
|
|
;; When the batch endpoint fails, the legacy report must still
|
|
;; have been sent successfully.
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}
|
|
batch-mock {:target 'app.tasks.telemetry/send-event-batch
|
|
:return false}]
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(insert-telemetry-row! "navigate")
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
;; Legacy report was sent
|
|
(t/is (:called? @legacy-mock))
|
|
(let [[_ data] (:call-args @legacy-mock)]
|
|
(t/is (= :telemetry-legacy-report (:type data))))
|
|
|
|
;; Batch send was attempted but failed
|
|
(t/is (:called? @batch-mock))
|
|
;; Row still present (not deleted on failure)
|
|
(t/is (= 1 (count-telemetry-rows))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; GC + BATCH FAILURE INTERACTION
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-gc-runs-even-when-batch-fails
|
|
;; GC must purge stale events regardless of whether the subsequent
|
|
;; batch send succeeds or fails.
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}
|
|
batch-mock {:target 'app.tasks.telemetry/send-event-batch
|
|
:return false}]
|
|
(with-redefs [cf/flags #{:telemetry}]
|
|
(let [eight-days (ct/minus (ct/now) (ct/duration {:days 8}))
|
|
one-day (ct/minus (ct/now) (ct/duration {:days 1}))]
|
|
;; Stale events (should be GC'd)
|
|
(insert-telemetry-row! "stale-1" {:created-at eight-days :tracked-at eight-days})
|
|
(insert-telemetry-row! "stale-2" {:created-at eight-days :tracked-at eight-days})
|
|
;; Fresh event (should survive GC but fail to send)
|
|
(insert-telemetry-row! "fresh" {:created-at one-day :tracked-at one-day})
|
|
|
|
(t/is (= 3 (count-telemetry-rows)))
|
|
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
|
|
;; Batch send was attempted (and failed)
|
|
(t/is (:called? @batch-mock))
|
|
;; Stale rows were purged by GC, fresh row remains
|
|
(t/is (= 1 (count-telemetry-rows)))
|
|
(t/is (= "fresh" (:name (first (th/db-exec! ["SELECT name FROM audit_log WHERE source LIKE 'telemetry:%'"])))))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; ROW->EVENT CONTEXT GUARANTEE
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-row->event-always-includes-context
|
|
;; row->event must always include :context as a map, even when the
|
|
;; DB column contains an empty transit object.
|
|
(let [row->event (ns-resolve 'app.tasks.telemetry 'row->event)]
|
|
;; With non-empty context
|
|
(let [ev (row->event {:name "test" :type "action" :source "telemetry:backend"
|
|
:tracked-at (ct/now) :profile-id uuid/zero
|
|
:context (db/tjson {:browser "Chrome"})})]
|
|
(t/is (contains? ev :context))
|
|
(t/is (= {:browser "Chrome"} (:context ev))))
|
|
|
|
;; With empty context ({} in transit)
|
|
(let [ev (row->event {:name "test" :type "action" :source "telemetry:backend"
|
|
:tracked-at (ct/now) :profile-id uuid/zero
|
|
:context (db/tjson {})})]
|
|
(t/is (contains? ev :context))
|
|
(t/is (= {} (:context ev))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; NO DUPLICATE EVENTS ON SUCCESS
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(t/deftest test-no-duplicate-events-after-successful-send
|
|
;; After a successful batch send, the sent rows must be deleted.
|
|
;; Running the task again must NOT re-send the same events.
|
|
(let [send-count (atom 0)]
|
|
(with-mocks [legacy-mock {:target 'app.tasks.telemetry/make-legacy-request
|
|
:return nil}]
|
|
(with-redefs [cf/flags #{:telemetry}
|
|
telemetry/send-event-batch
|
|
(fn [_cfg _batch]
|
|
(swap! send-count inc)
|
|
true)]
|
|
(insert-telemetry-row! "navigate")
|
|
(insert-telemetry-row! "create-file")
|
|
|
|
(t/is (= 2 (count-telemetry-rows)))
|
|
|
|
;; First run: sends and deletes
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
(t/is (= 1 @send-count))
|
|
(t/is (= 0 (count-telemetry-rows)))
|
|
|
|
;; Second run: no events to send
|
|
(th/run-task! :telemetry {:send? true :enabled? true})
|
|
(t/is (= 1 @send-count)) ;; still 1, not 2
|
|
(t/is (= 0 (count-telemetry-rows)))))))
|