diff --git a/.github/workflows/analytics.yml b/.github/workflows/analytics.yml new file mode 100644 index 00000000000..6d117a04ff0 --- /dev/null +++ b/.github/workflows/analytics.yml @@ -0,0 +1,111 @@ +name: Analytics + +on: + pull_request: + paths: + - ".github/workflows/analytics.yml" + - "package.json" + - "pnpm-lock.yaml" + - "packages/local-docker/**" + - "packages/analytics/**" + - "packages/env/server.ts" + - "packages/web-backend/src/ProductAnalytics/**" + - "apps/web/actions/analytics/**" + - "apps/web/actions/organization/**" + - "apps/web/__tests__/unit/*analytics*.test.ts" + - "apps/web/__tests__/unit/developer-credits-webhook.test.ts" + - "apps/web/app/Layout/*Analytics*.tsx" + - "apps/web/app/api/events/**" + - "apps/web/app/api/invite/accept/route.ts" + - "apps/web/app/api/settings/billing/**" + - "apps/web/app/api/webhooks/stripe/route.ts" + - "apps/web/app/utils/analytics.ts" + - "apps/web/app/utils/product-analytics.ts" + - "apps/web/lib/analytics/**" + - "apps/web/lib/rate-limit.ts" + - "apps/desktop/src-tauri/src/lib.rs" + - "apps/desktop/src/utils/analytics.ts" + - "apps/desktop/src/utils/product-analytics.ts" + - "apps/desktop/src/utils/*analytics*.test.ts" + - "apps/desktop/src-tauri/src/product_analytics.rs" + - "scripts/analytics/**" + push: + branches: + - main + paths: + - ".github/workflows/analytics.yml" + - "package.json" + - "pnpm-lock.yaml" + - "packages/local-docker/**" + - "packages/analytics/**" + - "packages/env/server.ts" + - "packages/web-backend/src/ProductAnalytics/**" + - "apps/web/actions/analytics/**" + - "apps/web/actions/organization/**" + - "apps/web/__tests__/unit/*analytics*.test.ts" + - "apps/web/__tests__/unit/developer-credits-webhook.test.ts" + - "apps/web/app/Layout/*Analytics*.tsx" + - "apps/web/app/api/events/**" + - "apps/web/app/api/invite/accept/route.ts" + - "apps/web/app/api/settings/billing/**" + - "apps/web/app/api/webhooks/stripe/route.ts" + - "apps/web/app/utils/analytics.ts" + - "apps/web/app/utils/product-analytics.ts" + - "apps/web/lib/analytics/**" + - "apps/web/lib/rate-limit.ts" + - "apps/desktop/src-tauri/src/lib.rs" + - "apps/desktop/src/utils/analytics.ts" + - "apps/desktop/src/utils/product-analytics.ts" + - "apps/desktop/src/utils/*analytics*.test.ts" + - "apps/desktop/src-tauri/src/product_analytics.rs" + - "scripts/analytics/**" + workflow_dispatch: + inputs: + deploy: + description: Deploy the checked Tinybird project to production + required: true + default: false + type: boolean + +permissions: + contents: read + +jobs: + validate: + name: Validate and test analytics + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: ./.github/actions/setup-js + - name: Run analytics regression suites + run: pnpm analytics:test + - name: Validate Docker Compose + run: node scripts/analytics/analytics-cli.js compose-check + - name: Build and test with Tinybird Local + run: node scripts/analytics/analytics-cli.js local + - name: Stop Tinybird Local + if: always() + run: node scripts/analytics/analytics-cli.js local-stop + + deploy: + name: Deploy analytics + needs: validate + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || inputs.deploy == true) + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: analytics-production + cancel-in-progress: false + environment: production + env: + TINYBIRD_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_DEPLOY_TOKEN }} + TINYBIRD_URL: ${{ secrets.TINYBIRD_URL }} + TINYBIRD_WORKSPACE_ID: ${{ secrets.TINYBIRD_WORKSPACE_ID }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 20 + - name: Check and deploy Tinybird project + run: node scripts/analytics/analytics-cli.js cloud-deploy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15285c97f6d..17fb42a2d1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,10 +151,6 @@ jobs: echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568" >> .env echo "NEXT_PUBLIC_WEB_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}" >> .env echo 'NEXTAUTH_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' >> .env - echo 'NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}' >> .env - echo 'NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}' >> .env - echo 'VITE_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}' >> .env - echo 'VITE_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}' >> .env echo 'VITE_SERVER_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' >> .env cat .env >> $GITHUB_ENV @@ -222,10 +218,6 @@ jobs: echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568" >> .env echo "NEXT_PUBLIC_WEB_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}" >> .env echo 'NEXTAUTH_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' >> .env - echo 'NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}' >> .env - echo 'NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}' >> .env - echo 'VITE_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}' >> .env - echo 'VITE_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}' >> .env echo 'VITE_SERVER_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' >> .env - name: Copy .env to apps/desktop diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 58b2f5ef899..78f3443bcad 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -285,8 +285,6 @@ jobs: echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568" echo "NEXT_PUBLIC_WEB_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}" echo 'NEXTAUTH_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' - echo 'VITE_POSTHOG_KEY=${{ secrets.VITE_POSTHOG_KEY }}' - echo 'VITE_POSTHOG_HOST=${{ secrets.VITE_POSTHOG_HOST }}' echo 'VITE_SERVER_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' echo 'RUST_TARGET_TRIPLE=${{ matrix.settings.target }}' } >> .env diff --git a/Cargo.lock b/Cargo.lock index e44be582793..411fca9b632 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -711,9 +711,9 @@ dependencies = [ "bytes", "futures-util", "http 1.3.1", - "http-body 1.0.1", + "http-body", "http-body-util", - "hyper 1.7.0", + "hyper", "hyper-util", "itoa", "matchit", @@ -727,7 +727,7 @@ dependencies = [ "serde_path_to_error", "serde_urlencoded", "sha1", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-tungstenite", "tower", @@ -746,12 +746,12 @@ dependencies = [ "bytes", "futures-util", "http 1.3.1", - "http-body 1.0.1", + "http-body", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper 1.0.2", + "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -1218,7 +1218,7 @@ dependencies = [ "libc", "open", "relative-path", - "reqwest 0.12.24", + "reqwest", "rmcp", "rpassword", "scap-screencapturekit", @@ -1244,7 +1244,7 @@ dependencies = [ name = "cap-api" version = "0.0.0" dependencies = [ - "reqwest 0.12.24", + "reqwest", "workspace-hack", ] @@ -1494,11 +1494,10 @@ dependencies = [ "parakeet-rs", "png 0.17.16", "pollster", - "posthog-rs", "rand 0.8.5", "regex", "relative-path", - "reqwest 0.12.24", + "reqwest", "rodio", "sanitize-filename", "scap-direct3d", @@ -1551,7 +1550,7 @@ dependencies = [ "windows 0.60.0", "windows-core 0.61.2", "windows-sys 0.59.0", - "winreg 0.55.0", + "winreg", "workspace-hack", ] @@ -3393,7 +3392,7 @@ dependencies = [ "rustc_version", "toml 0.9.7", "vswhom", - "winreg 0.55.0", + "winreg", ] [[package]] @@ -4464,25 +4463,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.11.4", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.12" @@ -4650,17 +4630,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -4680,7 +4649,7 @@ dependencies = [ "bytes", "futures-core", "http 1.3.1", - "http-body 1.0.1", + "http-body", "pin-project-lite", ] @@ -4702,30 +4671,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.7.0" @@ -4736,9 +4681,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.12", + "h2", "http 1.3.1", - "http-body 1.0.1", + "http-body", "httparse", "httpdate", "itoa", @@ -4749,20 +4694,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.7" @@ -4770,14 +4701,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.3.1", - "hyper 1.7.0", + "hyper", "hyper-util", - "rustls 0.23.31", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.3", + "tokio-rustls", "tower-service", - "webpki-roots 1.0.2", + "webpki-roots", ] [[package]] @@ -4788,7 +4719,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.7.0", + "hyper", "hyper-util", "native-tls", "tokio", @@ -4808,14 +4739,14 @@ dependencies = [ "futures-core", "futures-util", "http 1.3.1", - "http-body 1.0.1", - "hyper 1.7.0", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2 0.6.0", - "system-configuration 0.6.1", + "system-configuration", "tokio", "tower-service", "tracing", @@ -7239,7 +7170,7 @@ dependencies = [ "bytes", "http 1.3.1", "opentelemetry", - "reqwest 0.12.24", + "reqwest", ] [[package]] @@ -7254,7 +7185,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest 0.12.24", + "reqwest", "thiserror 2.0.16", "tracing", ] @@ -7837,20 +7768,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "posthog-rs" -version = "0.3.7" -source = "git+https://github.com/CapSoftware/posthog-rs?rev=c7e9712be2f9a9122b1df685d5a067afa5415288#c7e9712be2f9a9122b1df685d5a067afa5415288" -dependencies = [ - "chrono", - "derive_builder", - "reqwest 0.11.27", - "semver", - "serde", - "serde_json", - "uuid", -] - [[package]] name = "potential_utf" version = "0.1.3" @@ -8106,7 +8023,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", - "rustls 0.23.31", + "rustls", "socket2 0.6.0", "thiserror 2.0.16", "tokio", @@ -8126,7 +8043,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash 2.1.1", - "rustls 0.23.31", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.16", @@ -8554,47 +8471,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-rustls 0.24.2", - "ipnet", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls 0.21.12", - "rustls-pemfile 1.0.4", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration 0.5.1", - "tokio", - "tokio-rustls 0.24.1", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 0.25.4", - "winreg 0.50.0", -] - [[package]] name = "reqwest" version = "0.12.24" @@ -8608,12 +8484,12 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.12", + "h2", "http 1.3.1", - "http-body 1.0.1", + "http-body", "http-body-util", - "hyper 1.7.0", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", @@ -8624,15 +8500,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.31", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.3", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -8642,7 +8518,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.2", + "webpki-roots", ] [[package]] @@ -8893,18 +8769,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.31" @@ -8914,20 +8778,11 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.6", + "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -8947,16 +8802,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.6" @@ -9232,16 +9077,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "sctk-adwaita" version = "0.10.1" @@ -9361,7 +9196,7 @@ checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030" dependencies = [ "httpdate", "native-tls", - "reqwest 0.12.24", + "reqwest", "sentry-actix", "sentry-anyhow", "sentry-backtrace", @@ -10306,12 +10141,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -10369,17 +10198,6 @@ dependencies = [ "windows 0.61.3", ] -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys 0.5.0", -] - [[package]] name = "system-configuration" version = "0.6.1" @@ -10388,17 +10206,7 @@ checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.13.1", "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", + "system-configuration-sys", ] [[package]] @@ -10541,7 +10349,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.12.24", + "reqwest", "serde", "serde_json", "serde_repr", @@ -10762,7 +10570,7 @@ dependencies = [ "data-url", "http 1.3.1", "regex", - "reqwest 0.12.24", + "reqwest", "schemars 0.8.22", "serde", "serde_json", @@ -10958,7 +10766,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.12.24", + "reqwest", "semver", "serde", "serde_json", @@ -11420,23 +11228,13 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ - "rustls 0.23.31", + "rustls", "tokio", ] @@ -11608,11 +11406,11 @@ dependencies = [ "base64 0.22.1", "bytes", "http 1.3.1", - "http-body 1.0.1", + "http-body", "http-body-util", "percent-encoding", "pin-project", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio-stream", "tower-layer", "tower-service", @@ -11639,7 +11437,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -11656,7 +11454,7 @@ dependencies = [ "bytes", "futures-util", "http 1.3.1", - "http-body 1.0.1", + "http-body", "iri-string", "pin-project-lite", "tower", @@ -12086,7 +11884,7 @@ dependencies = [ "log", "native-tls", "percent-encoding", - "rustls-pemfile 2.2.0", + "rustls-pemfile", "rustls-pki-types", "socks", "ureq-proto", @@ -12584,12 +12382,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - [[package]] name = "webpki-roots" version = "1.0.2" @@ -13609,16 +13401,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winreg" version = "0.55.0" @@ -13699,7 +13481,7 @@ dependencies = [ "regex", "regex-automata", "regex-syntax", - "reqwest 0.12.24", + "reqwest", "rgb", "rustc-hash 1.1.0", "schemars 0.8.22", diff --git a/Cargo.toml b/Cargo.toml index 5c9c2cab521..91e45dbbdc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,8 +119,5 @@ cidre = { git = "https://github.com/CapSoftware/cidre", rev = "bf84b67079a8" } wgpu-hal = { path = "vendor/wgpu-hal" } tao = { path = "vendor/tao" } -# https://github.com/CapSoftware/posthog-rs/commit/c7e9712be2f9a9122b1df685d5a067afa5415288 -posthog-rs = { git = "https://github.com/CapSoftware/posthog-rs", rev = "c7e9712be2f9a9122b1df685d5a067afa5415288" } - # https://github.com/CapSoftware/reqwest/commit/9b5ecbd5210a9510fde766015cabb724c1e70d2e reqwest = { git = "https://github.com/CapSoftware/reqwest", rev = "9b5ecbd5210a9510fde766015cabb724c1e70d2e" } diff --git a/README.md b/README.md index f8943348fba..b0bba710b23 100644 --- a/README.md +++ b/README.md @@ -176,14 +176,17 @@ The web API uses Effect and `@effect/platform` HTTP APIs. Desktop capture and ex ## Analytics -Cap uses [Tinybird](https://www.tinybird.co) for viewer telemetry dashboards. Set `TINYBIRD_ADMIN_TOKEN` or `TINYBIRD_TOKEN` before running analytics commands. +Cap uses [Tinybird](https://www.tinybird.co) for product and viewer analytics. The product event collector uses a separate append-only token, while agents use read-only query access. | Command | Purpose | | --- | --- | -| `pnpm analytics:setup` | Deploy Tinybird datasources and pipes from `scripts/analytics/tinybird` | +| `pnpm analytics:local` | Start the optional Tinybird Local Docker profile, build resources, run fixtures, and write `.env.analytics.local` | +| `pnpm analytics:test` | Run deterministic infrastructure tests and static validation | +| `pnpm analytics:deploy:check` | Validate a cloud deployment without promoting it | +| `pnpm analytics:deploy` | Safely deploy checked-in Tinybird resources | | `pnpm analytics:check` | Validate that the Tinybird workspace matches the app expectations | -`analytics:setup` can remove Tinybird resources outside the checked-in analytics configuration. Use it only against the workspace you intend to manage from this repo. +Routine analytics commands reject destructive operations. See [scripts/analytics/README.md](scripts/analytics/README.md) for local setup, scoped credentials, deployment, and agent access. ## Contributing diff --git a/apps/desktop/app.config.ts b/apps/desktop/app.config.ts index cfa423e390d..ba12bcfeb39 100644 --- a/apps/desktop/app.config.ts +++ b/apps/desktop/app.config.ts @@ -71,7 +71,6 @@ export default defineConfig({ "@tauri-apps/api/webviewWindow", "@tauri-apps/plugin-dialog", "@tauri-apps/plugin-store", - "posthog-js", "uuid", "@tauri-apps/plugin-clipboard-manager", "@tauri-apps/api/window", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3eb2586b91e..5b6ba478c5d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@aerofoil/rive-solid-canvas": "^2.1.4", + "@cap/analytics": "workspace:*", "@cap/database": "workspace:*", "@cap/ui-solid": "workspace:*", "@cap/utils": "workspace:*", @@ -61,7 +62,6 @@ "effect": "^3.18.4", "lz4-wasm": "^0.9.2", "mp4box": "^0.5.2", - "posthog-js": "^1.215.3", "solid-js": "^1.9.3", "solid-markdown": "^2.0.13", "solid-presence": "^0.1.8", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index c560a028796..c27e3927778 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -120,7 +120,6 @@ tracing-opentelemetry = "0.32.0" opentelemetry = "0.31.0" opentelemetry-otlp = "0.31.0" #{ version = , features = ["http-proto", "reqwest-client"] } opentelemetry_sdk = { version = "0.31.0", features = ["rt-tokio", "trace"] } -posthog-rs = "0.3.7" sysinfo = "0.35" workspace-hack = { version = "0.1", path = "../../../crates/workspace-hack" } aho-corasick.workspace = true diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 7d6e422a385..7bead04264d 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -430,7 +430,7 @@ impl GeneralSettingsStore { store.set("general_settings", json!(settings)); store.save().map_err(|e| e.to_string())?; - crate::posthog::set_telemetry_enabled(settings.enable_telemetry); + crate::product_analytics::set_telemetry_enabled(settings.enable_telemetry); #[cfg(target_os = "macos")] crate::permissions::sync_macos_dock_visibility(app); @@ -492,7 +492,7 @@ pub fn init(app: &AppHandle) { raw_store.set(REMOVE_TARGET_SELECT_MIGRATION_KEY, json!(true)); } - crate::posthog::set_telemetry_enabled(store.enable_telemetry); + crate::product_analytics::set_telemetry_enabled(store.enable_telemetry); register_bundled_muxer_binary(app); #[cfg(target_os = "macos")] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d8cc8d4d546..80974523474 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -30,9 +30,9 @@ mod notifications; mod panel_manager; mod permissions; mod platform; -mod posthog; mod power_observer; mod presets; +mod product_analytics; mod recording; mod recording_settings; mod recording_telemetry; @@ -4459,7 +4459,6 @@ async fn check_notification_permissions(app: AppHandle) { #[instrument(skip(app))] async fn set_server_url(app: MutableState<'_, App>, server_url: String) -> Result<(), ()> { let mut app = app.write().await; - posthog::set_server_url(&server_url); app.server_url = server_url; Ok(()) @@ -4820,8 +4819,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { camera::init_preview_profile(system.total_memory()); } - posthog::init(); - let tauri_context = tauri::generate_context!(); let specta_builder = tauri_specta::Builder::new() @@ -5201,6 +5198,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { specta_builder.mount_events(&app); hotkeys::init(&app); general_settings::init(&app); + product_analytics::init_product_session(&app); configure_camera_blur_recovery(&app, previous_termination); fake_window::init(&app); app.manage(target_select_overlay::WindowFocusManager::default()); @@ -5304,8 +5302,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .ok(); } - posthog::set_server_url(&server_url); - let camera_preview = CameraPreviewManager::new(&app); let camera_session_id_handle = camera_preview.session_id_handle(); diff --git a/apps/desktop/src-tauri/src/posthog.rs b/apps/desktop/src-tauri/src/posthog.rs deleted file mode 100644 index 54872b2b380..00000000000 --- a/apps/desktop/src-tauri/src/posthog.rs +++ /dev/null @@ -1,412 +0,0 @@ -use std::{ - sync::{ - OnceLock, PoisonError, RwLock, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; -use tauri::AppHandle; -use tracing::error; - -use crate::auth::AuthStore; - -#[derive(Debug)] -pub enum PostHogEvent { - MultipartUploadComplete { - duration: Duration, - length: Duration, - size: u64, - }, - MultipartUploadFailed { - duration: Duration, - error: String, - }, - RecordingStarted { - mode: &'static str, - target_kind: &'static str, - has_camera: bool, - has_mic: bool, - has_system_audio: bool, - target_fps: u32, - target_width: u32, - target_height: u32, - fragmented: bool, - custom_cursor_capture: bool, - }, - RecordingCompleted { - mode: &'static str, - status: &'static str, - duration_secs: u64, - segment_count: u32, - track_failure_count: u32, - error_class: Option, - video_frames_captured: u64, - video_frames_dropped: u64, - drop_rate_pct: f64, - capture_stalls_count: u64, - capture_stalls_max_ms: u64, - mixer_stalls_count: u64, - mixer_stalls_max_ms: u64, - audio_gaps_count: u64, - audio_gaps_total_ms: u64, - frame_drop_rate_high_count: u64, - source_restarts_count: u64, - muxer_crash_count: u64, - audio_degraded_count: u64, - dropped_mic_messages: u64, - }, - RecordingMuxerCrashed { - mode: &'static str, - reason: String, - seconds_into_recording: f64, - }, - RecordingAudioDegraded { - mode: &'static str, - reason: String, - seconds_into_recording: f64, - }, - RecordingRecovered { - trigger: &'static str, - recovered_duration_secs: u64, - segments_recovered: u32, - validation_took_ms: u64, - }, - RecordingRecoveryFailed { - trigger: &'static str, - reason: String, - }, - RecordingDiskSpaceLow { - mode: &'static str, - bytes_remaining: u64, - }, - RecordingDiskSpaceExhausted { - mode: &'static str, - bytes_remaining: u64, - }, - RecordingDeviceLost { - mode: &'static str, - subsystem: String, - }, - RecordingEncoderRebuilt { - mode: &'static str, - backend: String, - attempt: u32, - }, - RecordingSourceAudioReset { - mode: &'static str, - source: String, - starvation_ms: u64, - }, - RecordingCaptureTargetLost { - mode: &'static str, - target: String, - }, -} - -fn truncate_reason(mut s: String) -> String { - const MAX_LEN: usize = 240; - if s.len() > MAX_LEN { - s.truncate(MAX_LEN); - s.push('…'); - } - s -} - -fn posthog_event(event: PostHogEvent, distinct_id: Option<&str>) -> posthog_rs::Event { - fn make_event(name: &str, distinct_id: Option<&str>) -> posthog_rs::Event { - match distinct_id { - Some(id) => posthog_rs::Event::new(name, id), - None => posthog_rs::Event::new_anon(name), - } - } - - fn set(e: &mut posthog_rs::Event, key: &str, value: impl serde::Serialize) { - e.insert_prop(key, value) - .map_err(|err| error!("Error adding PostHog property {key}: {err:?}")) - .ok(); - } - - match event { - PostHogEvent::MultipartUploadComplete { - duration, - length, - size, - } => { - let mut e = make_event("multipart_upload_complete", distinct_id); - set(&mut e, "duration", duration.as_secs()); - set(&mut e, "length", length.as_secs()); - set(&mut e, "size", size); - e - } - PostHogEvent::MultipartUploadFailed { duration, error } => { - let mut e = make_event("multipart_upload_failed", distinct_id); - set(&mut e, "duration", duration.as_secs()); - set(&mut e, "error", truncate_reason(error)); - e - } - PostHogEvent::RecordingStarted { - mode, - target_kind, - has_camera, - has_mic, - has_system_audio, - target_fps, - target_width, - target_height, - fragmented, - custom_cursor_capture, - } => { - let mut e = make_event("recording_started", distinct_id); - set(&mut e, "mode", mode); - set(&mut e, "target_kind", target_kind); - set(&mut e, "has_camera", has_camera); - set(&mut e, "has_mic", has_mic); - set(&mut e, "has_system_audio", has_system_audio); - set(&mut e, "target_fps", target_fps); - set(&mut e, "target_width", target_width); - set(&mut e, "target_height", target_height); - set(&mut e, "fragmented", fragmented); - set(&mut e, "custom_cursor_capture", custom_cursor_capture); - e - } - PostHogEvent::RecordingCompleted { - mode, - status, - duration_secs, - segment_count, - track_failure_count, - error_class, - video_frames_captured, - video_frames_dropped, - drop_rate_pct, - capture_stalls_count, - capture_stalls_max_ms, - mixer_stalls_count, - mixer_stalls_max_ms, - audio_gaps_count, - audio_gaps_total_ms, - frame_drop_rate_high_count, - source_restarts_count, - muxer_crash_count, - audio_degraded_count, - dropped_mic_messages, - } => { - let mut e = make_event("recording_completed", distinct_id); - set(&mut e, "mode", mode); - set(&mut e, "status", status); - set(&mut e, "duration_secs", duration_secs); - set(&mut e, "segment_count", segment_count); - set(&mut e, "track_failure_count", track_failure_count); - if let Some(ec) = error_class { - set(&mut e, "error_class", truncate_reason(ec)); - } - set(&mut e, "video_frames_captured", video_frames_captured); - set(&mut e, "video_frames_dropped", video_frames_dropped); - set( - &mut e, - "drop_rate_pct", - (drop_rate_pct * 100.0).round() / 100.0, - ); - set(&mut e, "capture_stalls_count", capture_stalls_count); - set(&mut e, "capture_stalls_max_ms", capture_stalls_max_ms); - set(&mut e, "mixer_stalls_count", mixer_stalls_count); - set(&mut e, "mixer_stalls_max_ms", mixer_stalls_max_ms); - set(&mut e, "audio_gaps_count", audio_gaps_count); - set(&mut e, "audio_gaps_total_ms", audio_gaps_total_ms); - set( - &mut e, - "frame_drop_rate_high_count", - frame_drop_rate_high_count, - ); - set(&mut e, "source_restarts_count", source_restarts_count); - set(&mut e, "muxer_crash_count", muxer_crash_count); - set(&mut e, "audio_degraded_count", audio_degraded_count); - set(&mut e, "dropped_mic_messages", dropped_mic_messages); - e - } - PostHogEvent::RecordingMuxerCrashed { - mode, - reason, - seconds_into_recording, - } => { - let mut e = make_event("recording_muxer_crashed", distinct_id); - set(&mut e, "mode", mode); - set(&mut e, "reason", truncate_reason(reason)); - set( - &mut e, - "seconds_into_recording", - (seconds_into_recording * 1000.0).round() / 1000.0, - ); - e - } - PostHogEvent::RecordingAudioDegraded { - mode, - reason, - seconds_into_recording, - } => { - let mut e = make_event("recording_audio_degraded", distinct_id); - set(&mut e, "mode", mode); - set(&mut e, "reason", truncate_reason(reason)); - set( - &mut e, - "seconds_into_recording", - (seconds_into_recording * 1000.0).round() / 1000.0, - ); - e - } - PostHogEvent::RecordingRecovered { - trigger, - recovered_duration_secs, - segments_recovered, - validation_took_ms, - } => { - let mut e = make_event("recording_recovered", distinct_id); - set(&mut e, "trigger", trigger); - set(&mut e, "recovered_duration_secs", recovered_duration_secs); - set(&mut e, "segments_recovered", segments_recovered); - set(&mut e, "validation_took_ms", validation_took_ms); - e - } - PostHogEvent::RecordingRecoveryFailed { trigger, reason } => { - let mut e = make_event("recording_recovery_failed", distinct_id); - set(&mut e, "trigger", trigger); - set(&mut e, "reason", truncate_reason(reason)); - e - } - PostHogEvent::RecordingDiskSpaceLow { - mode, - bytes_remaining, - } => { - let mut e = make_event("recording_disk_space_low", distinct_id); - set(&mut e, "mode", mode); - set(&mut e, "bytes_remaining", bytes_remaining); - e - } - PostHogEvent::RecordingDiskSpaceExhausted { - mode, - bytes_remaining, - } => { - let mut e = make_event("recording_disk_space_exhausted", distinct_id); - set(&mut e, "mode", mode); - set(&mut e, "bytes_remaining", bytes_remaining); - e - } - PostHogEvent::RecordingDeviceLost { mode, subsystem } => { - let mut e = make_event("recording_device_lost", distinct_id); - set(&mut e, "mode", mode); - set(&mut e, "subsystem", subsystem); - e - } - PostHogEvent::RecordingEncoderRebuilt { - mode, - backend, - attempt, - } => { - let mut e = make_event("recording_encoder_rebuilt", distinct_id); - set(&mut e, "mode", mode); - set(&mut e, "backend", backend); - set(&mut e, "attempt", attempt); - e - } - PostHogEvent::RecordingSourceAudioReset { - mode, - source, - starvation_ms, - } => { - let mut e = make_event("recording_source_audio_reset", distinct_id); - set(&mut e, "mode", mode); - set(&mut e, "source", source); - set(&mut e, "starvation_ms", starvation_ms); - e - } - PostHogEvent::RecordingCaptureTargetLost { mode, target } => { - let mut e = make_event("recording_capture_target_lost", distinct_id); - set(&mut e, "mode", mode); - set(&mut e, "target", target); - e - } - } -} - -pub fn init() { - if let Some(env) = option_env!("VITE_POSTHOG_KEY") { - tokio::spawn(async move { - posthog_rs::init_global(env) - .await - .map_err(|err| error!("Error initializing PostHog: {err}")) - .ok(); - }); - } -} - -pub fn set_server_url(url: &str) { - *API_SERVER_IS_CAP_CLOUD - .get_or_init(Default::default) - .write() - .unwrap_or_else(PoisonError::into_inner) = Some(url == "https://cap.so"); -} - -static API_SERVER_IS_CAP_CLOUD: OnceLock>> = OnceLock::new(); - -static TELEMETRY_ENABLED: AtomicBool = AtomicBool::new(true); - -pub fn set_telemetry_enabled(enabled: bool) { - TELEMETRY_ENABLED.store(enabled, Ordering::Release); -} - -pub fn telemetry_enabled() -> bool { - TELEMETRY_ENABLED.load(Ordering::Acquire) -} - -pub fn async_capture_event(app: &AppHandle, event: PostHogEvent) { - if option_env!("VITE_POSTHOG_KEY").is_none() { - return; - } - - let live_enabled = crate::general_settings::GeneralSettingsStore::get(app) - .ok() - .flatten() - .map(|s| s.enable_telemetry) - .unwrap_or_else(telemetry_enabled); - TELEMETRY_ENABLED.store(live_enabled, Ordering::Release); - if !live_enabled { - return; - } - - let distinct_id = AuthStore::get(app) - .ok() - .flatten() - .and_then(|auth| auth.user_id); - tokio::spawn(async move { - let mut e = posthog_event(event, distinct_id.as_deref()); - - e.insert_prop("cap_version", env!("CARGO_PKG_VERSION")) - .map_err(|err| error!("Error adding PostHog property: {err:?}")) - .ok(); - e.insert_prop( - "cap_backend", - match *API_SERVER_IS_CAP_CLOUD - .get_or_init(Default::default) - .read() - .unwrap_or_else(PoisonError::into_inner) - { - Some(true) => "cloud", - Some(false) => "self_hosted", - None => "unknown", - }, - ) - .map_err(|err| error!("Error adding PostHog property: {err:?}")) - .ok(); - e.insert_prop("os", std::env::consts::OS) - .map_err(|err| error!("Error adding PostHog property: {err:?}")) - .ok(); - e.insert_prop("arch", std::env::consts::ARCH) - .map_err(|err| error!("Error adding PostHog property: {err:?}")) - .ok(); - - posthog_rs::capture(e) - .await - .map_err(|err| error!("Error sending event to PostHog: {err:?}")) - .ok(); - }); -} diff --git a/apps/desktop/src-tauri/src/product_analytics.rs b/apps/desktop/src-tauri/src/product_analytics.rs new file mode 100644 index 00000000000..9a3d230cec0 --- /dev/null +++ b/apps/desktop/src-tauri/src/product_analytics.rs @@ -0,0 +1,611 @@ +use serde::Serialize; +use serde_json::{Map, Value}; +use std::{ + future::Future, + sync::{ + OnceLock, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Duration, +}; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; +use tokio::sync::mpsc; +use tracing::{debug, error, warn}; +use uuid::Uuid; + +use crate::{ + auth::{AuthSecret, AuthStore}, + general_settings::GeneralSettingsStore, + web_api::ManagerExt, +}; + +const PRODUCT_EVENT_QUEUE_CAPACITY: usize = 100; +const PRODUCT_EVENT_BATCH_SIZE: usize = 20; +const PRODUCT_EVENT_BATCH_DELAY: Duration = Duration::from_millis(250); +const PRODUCT_EVENT_RETRY_DELAY: Duration = Duration::from_millis(500); +const PRODUCT_EVENT_REQUEST_TIMEOUT: Duration = Duration::from_secs(3); +const PRODUCT_EVENT_SESSION_STORE_KEY: &str = "product_analytics_session_id"; + +#[derive(Debug)] +pub enum ProductAnalyticsEvent { + MultipartUploadComplete { + duration: Duration, + length: Duration, + size: u64, + }, + MultipartUploadFailed { + duration: Duration, + error: String, + }, + RecordingStarted { + mode: &'static str, + target_kind: &'static str, + has_camera: bool, + has_mic: bool, + has_system_audio: bool, + target_fps: u32, + target_width: u32, + target_height: u32, + fragmented: bool, + custom_cursor_capture: bool, + }, + RecordingCompleted { + mode: &'static str, + status: &'static str, + duration_secs: u64, + segment_count: u32, + track_failure_count: u32, + error_class: Option, + video_frames_captured: u64, + video_frames_dropped: u64, + drop_rate_pct: f64, + capture_stalls_count: u64, + capture_stalls_max_ms: u64, + mixer_stalls_count: u64, + mixer_stalls_max_ms: u64, + audio_gaps_count: u64, + audio_gaps_total_ms: u64, + frame_drop_rate_high_count: u64, + source_restarts_count: u64, + muxer_crash_count: u64, + audio_degraded_count: u64, + dropped_mic_messages: u64, + }, + RecordingRecoveryFailed { + trigger: &'static str, + reason: String, + }, +} + +fn truncate_reason(mut s: String) -> String { + const MAX_LEN: usize = 240; + if s.len() > MAX_LEN { + let mut end = MAX_LEN; + while !s.is_char_boundary(end) { + end = end.saturating_sub(1); + } + s.truncate(end); + s.push('…'); + } + s +} + +#[derive(Clone, Debug)] +struct EventData { + name: &'static str, + properties: Map, +} + +impl EventData { + fn new(name: &'static str) -> Self { + Self { + name, + properties: Map::new(), + } + } + + fn set(&mut self, key: &str, value: impl Serialize) { + match serde_json::to_value(value) { + Ok(value) => { + self.properties.insert(key.to_string(), value); + } + Err(err) => error!("Error serializing analytics property {key}: {err:?}"), + } + } +} + +fn event_data(event: ProductAnalyticsEvent) -> EventData { + match event { + ProductAnalyticsEvent::MultipartUploadComplete { + duration, + length, + size, + } => { + let mut data = EventData::new("multipart_upload_complete"); + data.set("duration", duration.as_secs()); + data.set("length", length.as_secs()); + data.set("size", size); + data + } + ProductAnalyticsEvent::MultipartUploadFailed { duration, error } => { + let mut data = EventData::new("multipart_upload_failed"); + data.set("duration", duration.as_secs()); + data.set("error", truncate_reason(error)); + data + } + ProductAnalyticsEvent::RecordingStarted { + mode, + target_kind, + has_camera, + has_mic, + has_system_audio, + target_fps, + target_width, + target_height, + fragmented, + custom_cursor_capture, + } => { + let mut data = EventData::new("recording_started"); + data.set("mode", mode); + data.set("target_kind", target_kind); + data.set("has_camera", has_camera); + data.set("has_mic", has_mic); + data.set("has_system_audio", has_system_audio); + data.set("target_fps", target_fps); + data.set("target_width", target_width); + data.set("target_height", target_height); + data.set("fragmented", fragmented); + data.set("custom_cursor_capture", custom_cursor_capture); + data + } + ProductAnalyticsEvent::RecordingCompleted { + mode, + status, + duration_secs, + segment_count, + track_failure_count, + error_class, + video_frames_captured, + video_frames_dropped, + drop_rate_pct, + capture_stalls_count, + capture_stalls_max_ms, + mixer_stalls_count, + mixer_stalls_max_ms, + audio_gaps_count, + audio_gaps_total_ms, + frame_drop_rate_high_count, + source_restarts_count, + muxer_crash_count, + audio_degraded_count, + dropped_mic_messages, + } => { + let mut data = EventData::new("recording_completed"); + data.set("mode", mode); + data.set("status", status); + data.set("duration_secs", duration_secs); + data.set("segment_count", segment_count); + data.set("track_failure_count", track_failure_count); + if let Some(ec) = error_class { + data.set("error_class", truncate_reason(ec)); + } + data.set("video_frames_captured", video_frames_captured); + data.set("video_frames_dropped", video_frames_dropped); + data.set("drop_rate_pct", (drop_rate_pct * 100.0).round() / 100.0); + data.set("capture_stalls_count", capture_stalls_count); + data.set("capture_stalls_max_ms", capture_stalls_max_ms); + data.set("mixer_stalls_count", mixer_stalls_count); + data.set("mixer_stalls_max_ms", mixer_stalls_max_ms); + data.set("audio_gaps_count", audio_gaps_count); + data.set("audio_gaps_total_ms", audio_gaps_total_ms); + data.set("frame_drop_rate_high_count", frame_drop_rate_high_count); + data.set("source_restarts_count", source_restarts_count); + data.set("muxer_crash_count", muxer_crash_count); + data.set("audio_degraded_count", audio_degraded_count); + data.set("dropped_mic_messages", dropped_mic_messages); + data + } + ProductAnalyticsEvent::RecordingRecoveryFailed { trigger, reason } => { + let mut data = EventData::new("recording_recovery_failed"); + data.set("trigger", trigger); + data.set("reason", truncate_reason(reason)); + data + } + } +} + +fn is_core_product_event(name: &str) -> bool { + matches!( + name, + "recording_started" + | "recording_completed" + | "multipart_upload_complete" + | "multipart_upload_failed" + | "recording_recovery_failed" + ) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProductEvent { + event_id: String, + event_name: &'static str, + occurred_at: String, + anonymous_id: String, + session_id: String, + platform: &'static str, + app_version: &'static str, + properties: Map, +} + +#[derive(Serialize)] +struct ProductEventBatch<'a> { + events: &'a [ProductEvent], +} + +fn product_event(data: &EventData, anonymous_id: String) -> Option { + if !is_core_product_event(data.name) { + return None; + } + + Some(ProductEvent { + event_id: Uuid::new_v4().to_string(), + event_name: data.name, + occurred_at: chrono::Utc::now().to_rfc3339(), + anonymous_id, + session_id: PRODUCT_EVENT_SESSION_ID + .get_or_init(Uuid::new_v4) + .to_string(), + platform: "desktop", + app_version: env!("CARGO_PKG_VERSION"), + properties: product_event_properties(data), + }) +} + +fn product_event_properties(data: &EventData) -> Map { + data.properties + .iter() + .filter(|(key, value)| { + !matches!( + key.as_str(), + "error" | "error_message" | "file_name" | "file_path" | "raw_error" | "reason" + ) && (value.is_null() || value.is_boolean() || value.is_number() || value.is_string()) + }) + .map(|(key, value)| { + let value = match value { + Value::String(value) => Value::String(truncate_reason(value.clone())), + value => value.clone(), + }; + (key.clone(), value) + }) + .collect() +} + +fn live_telemetry_enabled(app: &AppHandle) -> bool { + let enabled = GeneralSettingsStore::get(app) + .ok() + .flatten() + .map(|settings| settings.enable_telemetry) + .unwrap_or_else(telemetry_enabled); + TELEMETRY_ENABLED.store(enabled, Ordering::Release); + enabled +} + +fn product_auth_token(app: &AppHandle) -> Option { + AuthStore::get(app) + .ok() + .flatten() + .map(|auth| match auth.secret { + AuthSecret::ApiKey { api_key } => api_key, + AuthSecret::Session { token, .. } => token, + }) +} + +async fn send_product_batch_once(app: &AppHandle, events: &[ProductEvent]) -> Result<(), String> { + if !live_telemetry_enabled(app) { + return Ok(()); + } + + let auth_token = product_auth_token(app); + let response = app + .api_request("/api/events", |client, url| { + let request = client + .post(url) + .timeout(PRODUCT_EVENT_REQUEST_TIMEOUT) + .json(&ProductEventBatch { events }); + match &auth_token { + Some(token) => request.bearer_auth(token), + None => request, + } + }) + .await + .map_err(|err| err.to_string())?; + + if response.status().is_success() || !should_retry_product_status(response.status().as_u16()) { + Ok(()) + } else { + Err(format!( + "product analytics endpoint returned {}", + response.status() + )) + } +} + +fn should_retry_product_status(status: u16) -> bool { + status == 429 || status >= 500 +} + +async fn retry_once(mut operation: F, retry_delay: Duration) -> Result<(), E> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + if operation().await.is_ok() { + return Ok(()); + } + + tokio::time::sleep(retry_delay).await; + operation().await +} + +async fn run_product_event_worker(app: AppHandle, mut receiver: mpsc::Receiver) { + while let Some(first) = receiver.recv().await { + let mut events = Vec::with_capacity(PRODUCT_EVENT_BATCH_SIZE); + events.push(first); + let deadline = tokio::time::Instant::now() + PRODUCT_EVENT_BATCH_DELAY; + + while events.len() < PRODUCT_EVENT_BATCH_SIZE { + match tokio::time::timeout_at(deadline, receiver.recv()).await { + Ok(Some(event)) => events.push(event), + Ok(None) | Err(_) => break, + } + } + + if !live_telemetry_enabled(&app) { + continue; + } + + if let Err(err) = retry_once( + || send_product_batch_once(&app, &events), + PRODUCT_EVENT_RETRY_DELAY, + ) + .await + { + warn!( + event_count = events.len(), + "Dropping product analytics batch after one retry: {err}" + ); + } + } +} + +fn enqueue_product_event(app: &AppHandle, event: ProductEvent) { + let sender = PRODUCT_EVENT_SENDER.get_or_init(|| { + let (sender, receiver) = mpsc::channel(PRODUCT_EVENT_QUEUE_CAPACITY); + tokio::spawn(run_product_event_worker(app.clone(), receiver)); + sender + }); + + if let Err(err) = sender.try_send(event) { + let dropped = PRODUCT_EVENTS_DROPPED.fetch_add(1, Ordering::Relaxed) + 1; + if dropped.is_power_of_two() { + debug!( + dropped, + reason = %err, + "Product analytics queue is unavailable; event dropped" + ); + } + } +} + +pub fn init_product_session(app: &AppHandle) { + let session_id = PRODUCT_EVENT_SESSION_ID + .get_or_init(Uuid::new_v4) + .to_string(); + match app.store("store") { + Ok(store) => { + store.set(PRODUCT_EVENT_SESSION_STORE_KEY, session_id); + if let Err(err) = store.save() { + warn!("Failed to persist product analytics session ID: {err}"); + } + } + Err(err) => warn!("Failed to access store for product analytics session: {err}"), + } +} + +static TELEMETRY_ENABLED: AtomicBool = AtomicBool::new(true); +static PRODUCT_EVENT_SESSION_ID: OnceLock = OnceLock::new(); +static PRODUCT_EVENT_SENDER: OnceLock> = OnceLock::new(); +static PRODUCT_EVENTS_DROPPED: AtomicU64 = AtomicU64::new(0); + +pub fn set_telemetry_enabled(enabled: bool) { + TELEMETRY_ENABLED.store(enabled, Ordering::Release); +} + +pub fn telemetry_enabled() -> bool { + TELEMETRY_ENABLED.load(Ordering::Acquire) +} + +pub fn capture_event(app: &AppHandle, event: ProductAnalyticsEvent) { + if !live_telemetry_enabled(app) { + return; + } + + let anonymous_id = GeneralSettingsStore::get(app) + .ok() + .flatten() + .map(|settings| settings.instance_id.to_string()) + .unwrap_or_else(|| { + PRODUCT_EVENT_SESSION_ID + .get_or_init(Uuid::new_v4) + .to_string() + }); + let mut data = event_data(event); + data.set("cap_version", env!("CARGO_PKG_VERSION")); + data.set("os", std::env::consts::OS); + data.set("arch", std::env::consts::ARCH); + + if let Some(event) = product_event(&data, anonymous_id) { + enqueue_product_event(app, event); + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + fn recording_started() -> ProductAnalyticsEvent { + ProductAnalyticsEvent::RecordingStarted { + mode: "studio", + target_kind: "screen", + has_camera: true, + has_mic: true, + has_system_audio: false, + target_fps: 60, + target_width: 1920, + target_height: 1080, + fragmented: true, + custom_cursor_capture: true, + } + } + + #[test] + fn core_product_event_catalog_is_intentionally_small() { + let included = [ + "recording_started", + "recording_completed", + "multipart_upload_complete", + "multipart_upload_failed", + "recording_recovery_failed", + ]; + let excluded = [ + "recording_recovered", + "recording_muxer_crashed", + "recording_audio_degraded", + "recording_disk_space_low", + "recording_disk_space_exhausted", + "recording_device_lost", + "recording_encoder_rebuilt", + "recording_source_audio_reset", + "recording_capture_target_lost", + ]; + + assert!(included.into_iter().all(is_core_product_event)); + assert!(!excluded.into_iter().any(is_core_product_event)); + } + + #[test] + fn recording_started_has_scalar_properties() { + let data = event_data(recording_started()); + + assert_eq!(data.name, "recording_started"); + assert_eq!(data.properties["mode"], "studio"); + assert_eq!(data.properties["target_fps"], 60); + assert_eq!(data.properties["has_camera"], true); + assert!(data.properties.values().all(|value| { + value.is_null() || value.is_boolean() || value.is_number() || value.is_string() + })); + } + + #[test] + fn product_event_reuses_install_id_and_process_session() { + let data = event_data(recording_started()); + let first = product_event(&data, "install-id".to_string()).unwrap(); + let second = product_event(&data, "install-id".to_string()).unwrap(); + + assert_eq!(first.anonymous_id, "install-id"); + assert_eq!(second.anonymous_id, "install-id"); + assert_eq!(first.session_id, second.session_id); + assert_ne!(first.event_id, second.event_id); + assert_eq!(first.platform, "desktop"); + } + + #[test] + fn product_events_remove_raw_error_details_before_networking() { + let data = event_data(ProductAnalyticsEvent::MultipartUploadFailed { + duration: Duration::from_secs(2), + error: "/Users/private/recording.cap failed".to_string(), + }); + let event = product_event(&data, "install-id".to_string()).unwrap(); + + assert!(!event.properties.contains_key("error")); + assert_eq!(event.properties["duration"], 2); + } + + #[test] + fn truncation_is_safe_for_multibyte_text() { + let value = "🙂".repeat(100); + let truncated = truncate_reason(value); + + assert!(truncated.ends_with('…')); + assert!(truncated.len() <= 243); + assert!(truncated.is_char_boundary(truncated.len())); + } + + #[test] + fn batch_contract_uses_expected_camel_case_fields() { + let data = event_data(recording_started()); + let event = product_event(&data, "install-id".to_string()).unwrap(); + let json = serde_json::to_value(ProductEventBatch { events: &[event] }).unwrap(); + let serialized = &json["events"][0]; + + assert!(serialized.get("eventId").is_some()); + assert_eq!(serialized["eventName"], "recording_started"); + assert_eq!(serialized["anonymousId"], "install-id"); + assert_eq!(serialized["platform"], "desktop"); + assert!(serialized.get("occurredAt").is_some()); + assert!(serialized.get("sessionId").is_some()); + assert!(serialized.get("appVersion").is_some()); + } + + #[tokio::test] + async fn retry_once_stops_after_success() { + let attempts = AtomicUsize::new(0); + + let result = retry_once( + || { + let attempt = attempts.fetch_add(1, Ordering::Relaxed); + async move { + if attempt == 0 { + Err("temporary") + } else { + Ok(()) + } + } + }, + Duration::ZERO, + ) + .await; + + assert_eq!(result, Ok(())); + assert_eq!(attempts.load(Ordering::Relaxed), 2); + } + + #[tokio::test] + async fn retry_once_never_attempts_more_than_twice() { + let attempts = AtomicUsize::new(0); + + let result = retry_once( + || { + attempts.fetch_add(1, Ordering::Relaxed); + async { Err::<(), _>("offline") } + }, + Duration::ZERO, + ) + .await; + + assert_eq!(result, Err("offline")); + assert_eq!(attempts.load(Ordering::Relaxed), 2); + } + + #[test] + fn product_status_retry_policy_matches_the_browser() { + assert!(!should_retry_product_status(400)); + assert!(!should_retry_product_status(401)); + assert!(should_retry_product_status(429)); + assert!(should_retry_product_status(503)); + } +} diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index fe22e60dded..fc82f197922 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2180,110 +2180,8 @@ pub async fn start_recording( async move { let mut is_degraded = false; while let Some(event) = health_rx.recv().await { - if let Some((health, mode)) = accumulator_mode.as_ref() - && let Some((reason_text, critical)) = health.record_event(&event) - { - use crate::posthog::{PostHogEvent, async_capture_event}; - use crate::recording_telemetry::{CriticalEvent, mode_label}; - match critical { - CriticalEvent::MuxerCrashed { - seconds_into_recording, - .. - } => { - async_capture_event( - &app, - PostHogEvent::RecordingMuxerCrashed { - mode: mode_label(*mode), - reason: reason_text, - seconds_into_recording, - }, - ); - } - CriticalEvent::AudioDegraded { - seconds_into_recording, - .. - } => { - async_capture_event( - &app, - PostHogEvent::RecordingAudioDegraded { - mode: mode_label(*mode), - reason: reason_text, - seconds_into_recording, - }, - ); - } - } - } - - if let Some((_, mode)) = accumulator_mode.as_ref() { - use crate::posthog::{PostHogEvent, async_capture_event}; - use crate::recording_telemetry::mode_label; - let mode_str = mode_label(*mode); - match &event { - cap_recording::PipelineHealthEvent::DiskSpaceLow { - bytes_remaining, - .. - } => async_capture_event( - &app, - PostHogEvent::RecordingDiskSpaceLow { - mode: mode_str, - bytes_remaining: *bytes_remaining, - }, - ), - cap_recording::PipelineHealthEvent::DiskSpaceExhausted { - bytes_remaining, - } => async_capture_event( - &app, - PostHogEvent::RecordingDiskSpaceExhausted { - mode: mode_str, - bytes_remaining: *bytes_remaining, - }, - ), - cap_recording::PipelineHealthEvent::DeviceLost { subsystem } => { - async_capture_event( - &app, - PostHogEvent::RecordingDeviceLost { - mode: mode_str, - subsystem: subsystem.clone(), - }, - ) - } - cap_recording::PipelineHealthEvent::EncoderRebuilt { - backend, - attempt, - } => async_capture_event( - &app, - PostHogEvent::RecordingEncoderRebuilt { - mode: mode_str, - backend: backend.clone(), - attempt: *attempt, - }, - ), - cap_recording::PipelineHealthEvent::SourceAudioReset { - source, - starvation_ms, - } => async_capture_event( - &app, - PostHogEvent::RecordingSourceAudioReset { - mode: mode_str, - source: source.clone(), - starvation_ms: *starvation_ms, - }, - ), - cap_recording::PipelineHealthEvent::CaptureTargetLost { target } => { - async_capture_event( - &app, - PostHogEvent::RecordingCaptureTargetLost { - mode: mode_str, - target: target.clone(), - }, - ) - } - cap_recording::PipelineHealthEvent::RecoveryFragmentCorrupt { - .. - } => {} - _ => {} - } + if let Some((health, _)) = accumulator_mode.as_ref() { + let _ = health.record_event(&event); } let reason = match &event { @@ -3036,9 +2934,9 @@ async fn handle_recording_end( Some(feed) => feed.dropped_message_count().await, None => 0, }; - crate::posthog::async_capture_event( + crate::product_analytics::capture_event( &handle, - crate::posthog::PostHogEvent::RecordingCompleted { + crate::product_analytics::ProductAnalyticsEvent::RecordingCompleted { mode: crate::recording_telemetry::mode_label(mode), status, duration_secs, @@ -3978,13 +3876,11 @@ pub fn remux_fragmented_recording_with_trigger( if let Some(recording) = incomplete_recording { let normal_stop = trigger == "recording_stop"; - let validation_start = std::time::Instant::now(); let outcome = if normal_stop { RecoveryManager::finalize(&recording) } else { RecoveryManager::recover(&recording) }; - let validation_took_ms = validation_start.elapsed().as_millis() as u64; match outcome { Ok(_) => { @@ -3995,58 +3891,14 @@ pub fn remux_fragmented_recording_with_trigger( info!("Successfully recovered fragmented recording"); } - if let Some(app_handle) = app - && !normal_stop - { - let recovered_duration_secs = RecordingMeta::load_for_project(recording_dir) - .ok() - .and_then(|meta| match meta.inner { - RecordingMetaInner::Studio(studio) => match *studio { - StudioRecordingMeta::MultipleSegments { inner } => Some( - inner - .segments - .iter() - .filter_map(|seg| seg.display.start_time) - .fold(0.0_f64, |acc, v| acc.max(v)), - ), - StudioRecordingMeta::SingleSegment { .. } => None, - }, - _ => None, - }) - .map(|s| s as u64) - .unwrap_or_default(); - - let segments_recovered = RecordingMeta::load_for_project(recording_dir) - .ok() - .and_then(|meta| match meta.inner { - RecordingMetaInner::Studio(studio) => match *studio { - StudioRecordingMeta::MultipleSegments { inner } => { - Some(inner.segments.len() as u32) - } - StudioRecordingMeta::SingleSegment { .. } => Some(1), - }, - _ => None, - }) - .unwrap_or(0); - - crate::posthog::async_capture_event( - app_handle, - crate::posthog::PostHogEvent::RecordingRecovered { - trigger, - recovered_duration_secs, - segments_recovered, - validation_took_ms, - }, - ); - } Ok(()) } Err(e) => { let reason = format!("{e}"); if let Some(app_handle) = app { - crate::posthog::async_capture_event( + crate::product_analytics::capture_event( app_handle, - crate::posthog::PostHogEvent::RecordingRecoveryFailed { + crate::product_analytics::ProductAnalyticsEvent::RecordingRecoveryFailed { trigger, reason: reason.clone(), }, @@ -4086,7 +3938,7 @@ fn classify_error_message(error: &str) -> String { } async fn emit_recording_started_telemetry(app: &AppHandle, state_mtx: &MutableState<'_, App>) { - use crate::posthog::{PostHogEvent, async_capture_event}; + use crate::product_analytics::{ProductAnalyticsEvent, capture_event}; use crate::recording_telemetry::{mode_label, target_kind_label}; let (mode, recording_mode, target_kind, has_camera, has_mic, has_system_audio) = { @@ -4123,9 +3975,9 @@ async fn emit_recording_started_telemetry(app: &AppHandle, state_mtx: &MutableSt } }; - async_capture_event( + capture_event( app, - PostHogEvent::RecordingStarted { + ProductAnalyticsEvent::RecordingStarted { mode, target_kind, has_camera, diff --git a/apps/desktop/src-tauri/src/recovery.rs b/apps/desktop/src-tauri/src/recovery.rs index a280cc9f67c..a7d1826309b 100644 --- a/apps/desktop/src-tauri/src/recovery.rs +++ b/apps/desktop/src-tauri/src/recovery.rs @@ -82,15 +82,13 @@ pub async fn recover_recording(app: AppHandle, project_path: String) -> Result r, Err(e) => { let reason = format!("{e}"); - crate::posthog::async_capture_event( + crate::product_analytics::capture_event( &app, - crate::posthog::PostHogEvent::RecordingRecoveryFailed { + crate::product_analytics::ProductAnalyticsEvent::RecordingRecoveryFailed { trigger: "app_startup", reason: reason.clone(), }, @@ -98,8 +96,6 @@ pub async fn recover_recording(app: AppHandle, project_path: String) -> Result 1, StudioRecordingMeta::MultipleSegments { inner } => inner.segments.len(), @@ -110,16 +106,6 @@ pub async fn recover_recording(app: AppHandle, project_path: String) -> Result { segment.display.path.to_path(&recovered.project_path) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 74269ce441d..1d74626d695 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -4,7 +4,7 @@ use crate::{ UploadProgress, VideoUploadInfo, api::{self, PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}, http_client::{HttpClient, RetryableHttpClient}, - posthog::{PostHogEvent, async_capture_event}, + product_analytics::{ProductAnalyticsEvent, capture_event}, web_api::{AuthedApiError, ManagerExt}, }; use async_stream::{stream, try_stream}; @@ -214,10 +214,10 @@ pub async fn upload_video( emit_upload_complete(app, &video_id); - async_capture_event( + capture_event( app, match &video_result { - Ok(meta) => PostHogEvent::MultipartUploadComplete { + Ok(meta) => ProductAnalyticsEvent::MultipartUploadComplete { duration: start.elapsed(), length: meta .as_ref() @@ -227,7 +227,7 @@ pub async fn upload_video( .map(|m| ((m.len() as f64) / 1_000_000.0) as u64) .unwrap_or_default(), }, - Err(err) => PostHogEvent::MultipartUploadFailed { + Err(err) => ProductAnalyticsEvent::MultipartUploadFailed { duration: start.elapsed(), error: err.to_string(), }, @@ -551,10 +551,10 @@ impl InstantMultipartUpload { realtime_upload_done, ) .await; - async_capture_event( + capture_event( &app, match &result { - Ok(meta) => PostHogEvent::MultipartUploadComplete { + Ok(meta) => ProductAnalyticsEvent::MultipartUploadComplete { duration: start.elapsed(), length: meta .as_ref() @@ -564,7 +564,7 @@ impl InstantMultipartUpload { .map(|m| ((m.len() as f64) / 1_000_000.0) as u64) .unwrap_or_default(), }, - Err(err) => PostHogEvent::MultipartUploadFailed { + Err(err) => ProductAnalyticsEvent::MultipartUploadFailed { duration: start.elapsed(), error: err.to_string(), }, @@ -894,15 +894,15 @@ impl SegmentUploader { ) .await; - async_capture_event( + capture_event( &app, match &result { - Ok(total_bytes) => PostHogEvent::MultipartUploadComplete { + Ok(total_bytes) => ProductAnalyticsEvent::MultipartUploadComplete { duration: start.elapsed(), length: start.elapsed(), size: total_bytes / (1024 * 1024), }, - Err(err) => PostHogEvent::MultipartUploadFailed { + Err(err) => ProductAnalyticsEvent::MultipartUploadFailed { duration: start.elapsed(), error: err.to_string(), }, diff --git a/apps/desktop/src/app.tsx b/apps/desktop/src/app.tsx index be3971b4e4b..4af56a5c843 100644 --- a/apps/desktop/src/app.tsx +++ b/apps/desktop/src/app.tsx @@ -23,7 +23,6 @@ import { CapErrorBoundary } from "./components/CapErrorBoundary"; import WindowChromeLayout from "./routes/(window-chrome)"; import SettingsLayout from "./routes/(window-chrome)/settings"; import { generalSettingsStore } from "./store"; -import { initAnonymousUser } from "./utils/analytics"; import { type AppTheme, commands } from "./utils/tauri"; import titlebar from "./utils/titlebar-state"; @@ -126,7 +125,6 @@ function Inner() { createThemeListener(currentWindow); onMount(() => { - initAnonymousUser(); prewarmFontCaches(); }); diff --git a/apps/desktop/src/utils/analytics.test.ts b/apps/desktop/src/utils/analytics.test.ts new file mode 100644 index 00000000000..fb57a9606be --- /dev/null +++ b/apps/desktop/src/utils/analytics.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const state: { + enableTelemetry: boolean; + settingsListener?: (settings?: { enableTelemetry?: boolean }) => void; + } = { enableTelemetry: true }; + + return { + state, + fetch: vi.fn(async (_url: string, _request?: RequestInit) => ({ + ok: true, + status: 202, + })), + }; +}); + +vi.mock("@tauri-apps/api/app", () => ({ getVersion: async () => "0.5.6" })); +vi.mock("@tauri-apps/plugin-http", () => ({ fetch: mocks.fetch })); +vi.mock("@tauri-apps/plugin-store", () => ({ + Store: { + load: async () => ({ + get: async (key: string) => + key === "product_analytics_session_id" + ? "process-session-id" + : { enableTelemetry: mocks.state.enableTelemetry }, + onKeyChange: async ( + _key: string, + listener: (settings?: { enableTelemetry?: boolean }) => void, + ) => { + mocks.state.settingsListener = listener; + return () => {}; + }, + }), + }, +})); +vi.mock("~/store", () => ({ + generalSettingsStore: { + get: async () => ({ instanceId: "install-id" }), + }, +})); +vi.mock("./web-api", () => ({ + getConfiguredServerUrl: async () => "https://cap.so", + maybeProtectedHeaders: async () => ({ authorization: "Bearer token" }), +})); + +async function flushMicrotasks() { + for (let index = 0; index < 10; index++) await Promise.resolve(); +} + +async function loadAnalytics() { + const analytics = await import("./analytics"); + await flushMicrotasks(); + return analytics; +} + +describe("desktop analytics", () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mocks.state.enableTelemetry = true; + mocks.state.settingsListener = undefined; + mocks.fetch.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("sends a normalized core event through the first-party endpoint", async () => { + const { trackEvent } = await loadAnalytics(); + trackEvent("create_shareable_link_clicked", { + fps: 60, + has_existing_auth: true, + ignored: { nested: true }, + }); + await flushMicrotasks(); + await vi.advanceTimersByTimeAsync(250); + + expect(mocks.fetch).toHaveBeenCalledOnce(); + const [url, request] = mocks.fetch.mock.calls[0] ?? []; + expect(url).toBe("https://cap.so/api/events"); + expect(request).toMatchObject({ + method: "POST", + headers: { + authorization: "Bearer token", + "content-type": "application/json", + }, + }); + const body = JSON.parse(String(request?.body)); + expect(body.events).toHaveLength(1); + expect(body.events[0]).toMatchObject({ + eventName: "create_shareable_link_clicked", + anonymousId: "install-id", + sessionId: "process-session-id", + platform: "desktop", + appVersion: "0.5.6", + properties: { fps: 60, has_existing_auth: true }, + }); + }); + + it("drops events outside the bounded product catalog", async () => { + const { trackEvent } = await loadAnalytics(); + trackEvent("camera_selected", { source: "dropdown" }); + await flushMicrotasks(); + await vi.runAllTimersAsync(); + + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("never accepts a client-authored revenue event", async () => { + const { trackEvent } = await loadAnalytics(); + trackEvent("purchase_completed", { quantity: 10 }); + await flushMicrotasks(); + await vi.runAllTimersAsync(); + + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("drops permanent collector errors without retrying", async () => { + mocks.fetch.mockResolvedValue({ ok: false, status: 400 }); + const { trackEvent } = await loadAnalytics(); + trackEvent("export_button_clicked"); + await flushMicrotasks(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(mocks.fetch).toHaveBeenCalledOnce(); + }); + + it("retries transient collector errors once", async () => { + mocks.fetch.mockResolvedValue({ ok: false, status: 503 }); + const { trackEvent } = await loadAnalytics(); + trackEvent("export_button_clicked"); + await flushMicrotasks(); + await vi.advanceTimersByTimeAsync(250); + await vi.advanceTimersByTimeAsync(500); + + expect(mocks.fetch).toHaveBeenCalledTimes(2); + }); + + it("clears queued events as soon as telemetry is disabled", async () => { + const { trackEvent } = await loadAnalytics(); + trackEvent("export_button_clicked"); + await flushMicrotasks(); + mocks.state.settingsListener?.({ enableTelemetry: false }); + await vi.runAllTimersAsync(); + + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("does not send events when telemetry starts disabled", async () => { + mocks.state.enableTelemetry = false; + const { trackEvent } = await loadAnalytics(); + trackEvent("export_button_clicked"); + await vi.runAllTimersAsync(); + + expect(mocks.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/utils/analytics.ts b/apps/desktop/src/utils/analytics.ts index d50316d3eb0..cad580ccbc6 100644 --- a/apps/desktop/src/utils/analytics.ts +++ b/apps/desktop/src/utils/analytics.ts @@ -1,128 +1,209 @@ +import { + isCoreEventName, + isServerOnlyEventName, + normalizeProductEventProperties, + type ProductEventInput, +} from "@cap/analytics"; +import { getVersion } from "@tauri-apps/api/app"; +import { fetch } from "@tauri-apps/plugin-http"; import { Store } from "@tauri-apps/plugin-store"; -import posthog from "posthog-js"; import { v4 as uuid } from "uuid"; -const key = import.meta.env.VITE_POSTHOG_KEY as string; -const host = import.meta.env.VITE_POSTHOG_HOST as string; +import { generalSettingsStore } from "~/store"; +import { ProductAnalyticsQueue } from "./product-analytics"; +import { getConfiguredServerUrl, maybeProtectedHeaders } from "./web-api"; -let isPostHogInitialized = false; +const PRODUCT_ANALYTICS_REQUEST_TIMEOUT_MS = 3000; let telemetryEnabledCache = true; - -async function isTelemetryEnabled(): Promise { - try { - const store = await Store.load("store"); - const settings = - (await store.get<{ enableTelemetry?: boolean }>("general_settings")) ?? - null; - telemetryEnabledCache = settings?.enableTelemetry !== false; - } catch { - // fall back to cached value; defaults to enabled +let telemetryStateReady = false; +let telemetryStatePromise: Promise | undefined; +let anonymousIdPromise: Promise | undefined; +let appVersionPromise: Promise | undefined; +let productSessionIdPromise: Promise | undefined; +let fallbackAnonymousIdValue: string | undefined; +let activeProductRequest: AbortController | undefined; + +const productAnalyticsQueue = new ProductAnalyticsQueue({ + sendBatch: sendProductEventBatch, + isEnabled: isTelemetryEnabled, +}); + +function applyTelemetryState(enabled: boolean) { + telemetryEnabledCache = enabled; + if (!enabled) { + productAnalyticsQueue.clear(); + activeProductRequest?.abort(); } +} + +async function initializeTelemetryState() { + if (telemetryStatePromise) return telemetryStatePromise; + + telemetryStatePromise = (async () => { + try { + const store = await Store.load("store"); + const settings = await store.get<{ enableTelemetry?: boolean }>( + "general_settings", + ); + applyTelemetryState(settings?.enableTelemetry !== false); + await store.onKeyChange<{ enableTelemetry?: boolean }>( + "general_settings", + (settings) => applyTelemetryState(settings?.enableTelemetry !== false), + ); + } catch { + applyTelemetryState(telemetryEnabledCache); + } finally { + telemetryStateReady = true; + } + })(); + + return telemetryStatePromise; +} + +async function isTelemetryEnabled() { + if (!telemetryStateReady) await initializeTelemetryState(); return telemetryEnabledCache; } -if (key && host) { +function fallbackAnonymousId() { + if (fallbackAnonymousIdValue) return fallbackAnonymousIdValue; try { - posthog.init(key, { - api_host: host, - capture_pageview: false, - loaded: (_posthogInstance) => { - isPostHogInitialized = true; - }, - }); - console.log("PostHog initialization started"); - } catch (error) { - console.error("Failed to initialize PostHog:", error); + const storage = getAnalyticsStorage(); + const existing = storage?.getItem("anonymous_id"); + if (existing) { + fallbackAnonymousIdValue = existing; + return existing; + } + } catch {} + + fallbackAnonymousIdValue = uuid(); + try { + getAnalyticsStorage()?.setItem("anonymous_id", fallbackAnonymousIdValue); + } catch {} + return fallbackAnonymousIdValue; +} + +function getAnalyticsStorage() { + try { + return typeof window === "undefined" ? undefined : window.localStorage; + } catch { + return undefined; } } -export function initAnonymousUser() { - if (!key || !host) { - console.warn("Cannot initialize anonymous user - missing key or host"); - return; +async function getAnonymousId() { + if (!anonymousIdPromise) { + anonymousIdPromise = generalSettingsStore + .get() + .then((settings) => settings?.instanceId ?? fallbackAnonymousId()) + .then((anonymousId) => { + getAnalyticsStorage()?.setItem("anonymous_id", anonymousId); + return anonymousId; + }) + .catch(fallbackAnonymousId); } + return anonymousIdPromise; +} - try { - const anonymousId = localStorage.getItem("anonymous_id") ?? uuid(); - localStorage.setItem("anonymous_id", anonymousId); - posthog.identify(anonymousId); - console.log("Anonymous user identified:", anonymousId); - } catch (error) { - console.error("Error initializing anonymous user:", error); +async function getAppVersion() { + if (!appVersionPromise) { + appVersionPromise = getVersion().catch(() => undefined); } + return appVersionPromise; } -export function identifyUser( - userId: string, - properties?: Record, -) { - if (!key || !host) { - console.warn("Cannot identify user - missing key or host"); - return; +async function getProductSessionId() { + if (!productSessionIdPromise) { + productSessionIdPromise = Store.load("store") + .then((store) => store.get("product_analytics_session_id")) + .then((stored) => stored ?? uuid()) + .catch(uuid); } + return productSessionIdPromise; +} - try { - const currentId = posthog.get_distinct_id(); - const anonymousId = localStorage.getItem("anonymous_id"); +async function sendProductEventBatch(events: ProductEventInput[]) { + if (!(await isTelemetryEnabled())) return; - if (currentId !== userId) { - if (anonymousId && currentId === anonymousId) { - console.log(`Aliasing user ${userId} from anonymous ID ${anonymousId}`); - posthog.alias(userId, anonymousId); - } - posthog.identify(userId); - if (properties) { - posthog.people.set(properties); + const controller = new AbortController(); + activeProductRequest = controller; + const timeout = setTimeout( + () => controller.abort(), + PRODUCT_ANALYTICS_REQUEST_TIMEOUT_MS, + ); + + try { + const { authorization } = await maybeProtectedHeaders(); + const headers: Record = { + "content-type": "application/json", + }; + if (authorization) headers.authorization = authorization; + + const response = await fetch( + new URL("/api/events", await getConfiguredServerUrl()).toString(), + { + method: "POST", + headers, + body: JSON.stringify({ events }), + signal: controller.signal, + }, + ); + if (!response.ok) { + if (response.status === 429 || response.status >= 500) { + throw new Error(`Product analytics returned ${response.status}`); } - localStorage.removeItem("anonymous_id"); - console.log(`User identified: ${userId}`); - } else { - console.log(`User already identified as ${userId}`); } - } catch (error) { - console.error("Error identifying user:", error); + } finally { + clearTimeout(timeout); + if (activeProductRequest === controller) activeProductRequest = undefined; } } -export function trackEvent( +async function enqueueProductEvent( + eventId: string, eventName: string, + occurredAt: string, properties?: Record, ) { - if (!key || !host) { - console.warn( - "PostHog event not captured - missing key or host:", - eventName, - ); - return; - } + if (!isCoreEventName(eventName) || isServerOnlyEventName(eventName)) return; + + const [anonymousId, appVersion, productSessionId] = await Promise.all([ + getAnonymousId(), + getAppVersion(), + getProductSessionId(), + ]); + if (!telemetryEnabledCache) return; + + const normalizedProperties = normalizeProductEventProperties(properties); + productAnalyticsQueue.enqueue({ + eventId, + eventName, + occurredAt, + anonymousId, + sessionId: productSessionId, + platform: "desktop", + ...(appVersion ? { appVersion } : {}), + ...(normalizedProperties ? { properties: normalizedProperties } : {}), + }); +} - if (!telemetryEnabledCache) { - return; - } +export function trackEvent( + eventName: string, + properties?: Record, +) { + const eventId = uuid(); + const occurredAt = new Date().toISOString(); void isTelemetryEnabled().then((enabled) => { if (!enabled) return; - try { - if (!isPostHogInitialized) { - console.warn( - `PostHog not initialized yet, queuing event: ${eventName}`, - ); - setTimeout(() => { - console.log(`Retrying event ${eventName} after delay`); - trackEvent(eventName, properties); - }, 1000); - return; - } - - const eventProperties = { ...properties, platform: "desktop" }; - console.log(`Capturing event ${eventName}:`, eventProperties); - posthog.capture(eventName, eventProperties); - } catch (error) { - console.error(`Error capturing event ${eventName}:`, error); - } + void enqueueProductEvent(eventId, eventName, occurredAt, properties); }); } -void isTelemetryEnabled(); +if (typeof window !== "undefined") { + window.addEventListener("pagehide", () => void productAnalyticsQueue.flush()); +} + +void initializeTelemetryState(); diff --git a/apps/desktop/src/utils/auth.ts b/apps/desktop/src/utils/auth.ts index 60c55d0b8d3..cb82bb15f28 100644 --- a/apps/desktop/src/utils/auth.ts +++ b/apps/desktop/src/utils/auth.ts @@ -7,7 +7,7 @@ import * as shell from "@tauri-apps/plugin-shell"; import { z } from "zod"; import callbackTemplate from "~/components/callback.template"; import { authStore, generalSettingsStore } from "~/store"; -import { identifyUser, trackEvent } from "./analytics"; +import { trackEvent } from "./analytics"; import { clientEnv } from "./env"; import { shouldUseLocalServerSessionForUrl } from "./server-url-routing"; import { commands } from "./tauri"; @@ -216,7 +216,6 @@ function parseAuthParams(url: URL) { } async function processAuthData(data: AuthParams) { - identifyUser(data.user_id); trackEvent("user_signed_in", { platform: "desktop" }); await authStore.set({ diff --git a/apps/desktop/src/utils/product-analytics.test.ts b/apps/desktop/src/utils/product-analytics.test.ts new file mode 100644 index 00000000000..209e2b46cf8 --- /dev/null +++ b/apps/desktop/src/utils/product-analytics.test.ts @@ -0,0 +1,248 @@ +import type { ProductEventInput } from "@cap/analytics"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ProductAnalyticsQueue } from "./product-analytics"; + +function productEvent(index: number): ProductEventInput { + return { + eventId: `event-${index}`, + eventName: "recording_started", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "install-id", + sessionId: "session-id", + platform: "desktop", + appVersion: "0.5.6", + properties: { index }, + }; +} + +function sendBatchMock() { + return vi.fn(async (_events: ProductEventInput[]) => {}); +} + +describe("ProductAnalyticsQueue", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("batches events after the short collection window", async () => { + const sendBatch = sendBatchMock(); + const queue = new ProductAnalyticsQueue({ + sendBatch, + isEnabled: () => true, + batchDelayMs: 25, + }); + + queue.enqueue(productEvent(1)); + queue.enqueue(productEvent(2)); + expect(sendBatch).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(25); + + expect(sendBatch).toHaveBeenCalledOnce(); + expect(sendBatch.mock.calls[0]?.[0].map((event) => event.eventId)).toEqual([ + "event-1", + "event-2", + ]); + }); + + it("flushes immediately at the batch limit", async () => { + const sendBatch = sendBatchMock(); + const queue = new ProductAnalyticsQueue({ + sendBatch, + isEnabled: () => true, + batchSize: 3, + }); + + queue.enqueue(productEvent(1)); + queue.enqueue(productEvent(2)); + queue.enqueue(productEvent(3)); + await Promise.resolve(); + await Promise.resolve(); + + expect(sendBatch).toHaveBeenCalledOnce(); + expect(sendBatch.mock.calls[0]?.[0]).toHaveLength(3); + }); + + it("never sends more than the configured batch size", async () => { + const sizes: number[] = []; + const queue = new ProductAnalyticsQueue({ + sendBatch: async (events) => { + sizes.push(events.length); + }, + isEnabled: () => true, + batchSize: 5, + batchDelayMs: 10, + }); + + for (let index = 0; index < 13; index++) { + queue.enqueue(productEvent(index)); + } + await vi.runAllTimersAsync(); + + expect(sizes.reduce((total, size) => total + size, 0)).toBe(13); + expect(sizes.every((size) => size <= 5)).toBe(true); + }); + + it("keeps serialized requests under the byte limit", async () => { + const requestSizes: number[] = []; + const queue = new ProductAnalyticsQueue({ + sendBatch: async (events) => { + requestSizes.push( + new TextEncoder().encode(JSON.stringify({ events })).byteLength, + ); + }, + isEnabled: () => true, + batchDelayMs: 10, + maxBatchBytes: 1500, + }); + + for (let index = 0; index < 10; index++) { + queue.enqueue({ + ...productEvent(index), + properties: { value: "x".repeat(500) }, + }); + } + await vi.runAllTimersAsync(); + + expect(requestSizes).toHaveLength(5); + expect(requestSizes.every((size) => size <= 1500)).toBe(true); + }); + + it("bounds memory and drops the oldest queued event", async () => { + const sendBatch = sendBatchMock(); + const queue = new ProductAnalyticsQueue({ + sendBatch, + isEnabled: () => true, + batchSize: 10, + capacity: 3, + batchDelayMs: 10, + }); + + for (let index = 0; index < 4; index++) { + queue.enqueue(productEvent(index)); + } + await vi.advanceTimersByTimeAsync(10); + + expect(queue.dropped).toBe(1); + expect(sendBatch.mock.calls[0]?.[0].map((event) => event.eventId)).toEqual([ + "event-1", + "event-2", + "event-3", + ]); + }); + + it("does not send when telemetry is disabled", async () => { + const sendBatch = sendBatchMock(); + const queue = new ProductAnalyticsQueue({ + sendBatch, + isEnabled: () => false, + batchDelayMs: 10, + }); + + queue.enqueue(productEvent(1)); + await vi.advanceTimersByTimeAsync(10); + + expect(sendBatch).not.toHaveBeenCalled(); + expect(queue.size).toBe(0); + expect(queue.dropped).toBe(1); + }); + + it("retries a failed event once and then drops it", async () => { + const sendBatch = vi.fn(async (_events: ProductEventInput[]) => { + throw new Error("offline"); + }); + const queue = new ProductAnalyticsQueue({ + sendBatch, + isEnabled: () => true, + batchDelayMs: 10, + retryDelayMs: 20, + }); + + queue.enqueue(productEvent(1)); + await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(20); + + expect(sendBatch).toHaveBeenCalledTimes(2); + expect(queue.size).toBe(0); + expect(queue.dropped).toBe(1); + }); + + it("does not retry an in-flight request after telemetry is disabled", async () => { + let enabled = true; + let failRequest: (() => void) | undefined; + const sendBatch = vi.fn( + (_events: ProductEventInput[]) => + new Promise((_resolve, reject) => { + failRequest = () => reject(new Error("aborted")); + }), + ); + const queue = new ProductAnalyticsQueue({ + sendBatch, + isEnabled: () => enabled, + batchSize: 1, + retryDelayMs: 10, + }); + + queue.enqueue(productEvent(1)); + await Promise.resolve(); + enabled = false; + failRequest?.(); + await vi.runAllTimersAsync(); + + expect(sendBatch).toHaveBeenCalledOnce(); + expect(queue.size).toBe(0); + expect(queue.dropped).toBe(1); + }); + + it("keeps at most one request in flight", async () => { + let completeFirst: (() => void) | undefined; + let calls = 0; + const sendBatch = vi.fn((_events: ProductEventInput[]) => { + calls += 1; + if (calls > 1) return Promise.resolve(); + return new Promise((resolve) => { + completeFirst = resolve; + }); + }); + const queue = new ProductAnalyticsQueue({ + sendBatch, + isEnabled: () => true, + batchSize: 1, + batchDelayMs: 10, + }); + + queue.enqueue(productEvent(1)); + await Promise.resolve(); + queue.enqueue(productEvent(2)); + await queue.flush(); + + expect(sendBatch).toHaveBeenCalledOnce(); + completeFirst?.(); + await vi.advanceTimersByTimeAsync(10); + + expect(sendBatch).toHaveBeenCalledTimes(2); + }); + + it("can synchronously clear queued events on opt-out", async () => { + const sendBatch = sendBatchMock(); + const queue = new ProductAnalyticsQueue({ + sendBatch, + isEnabled: () => true, + batchDelayMs: 10, + }); + + queue.enqueue(productEvent(1)); + queue.enqueue(productEvent(2)); + queue.clear(); + await vi.runAllTimersAsync(); + + expect(queue.size).toBe(0); + expect(queue.dropped).toBe(2); + expect(sendBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/utils/product-analytics.ts b/apps/desktop/src/utils/product-analytics.ts new file mode 100644 index 00000000000..e6c0294fb5e --- /dev/null +++ b/apps/desktop/src/utils/product-analytics.ts @@ -0,0 +1,171 @@ +import { + PRODUCT_ANALYTICS_LIMITS, + type ProductEventInput, +} from "@cap/analytics"; + +export const PRODUCT_ANALYTICS_BATCH_SIZE = PRODUCT_ANALYTICS_LIMITS.batchSize; +export const PRODUCT_ANALYTICS_QUEUE_CAPACITY = + PRODUCT_ANALYTICS_LIMITS.queueSize; +export const PRODUCT_ANALYTICS_BATCH_DELAY_MS = 250; +export const PRODUCT_ANALYTICS_RETRY_DELAY_MS = 500; + +type QueuedEvent = { + event: ProductEventInput; + attempts: number; +}; + +type ProductAnalyticsQueueOptions = { + sendBatch: (events: ProductEventInput[]) => Promise; + isEnabled: () => boolean | Promise; + batchSize?: number; + capacity?: number; + batchDelayMs?: number; + retryDelayMs?: number; + maxBatchBytes?: number; + onDrop?: (count: number) => void; +}; + +export class ProductAnalyticsQueue { + readonly #sendBatch: ProductAnalyticsQueueOptions["sendBatch"]; + readonly #isEnabled: ProductAnalyticsQueueOptions["isEnabled"]; + readonly #batchSize: number; + readonly #capacity: number; + readonly #batchDelayMs: number; + readonly #retryDelayMs: number; + readonly #maxBatchBytes: number; + readonly #onDrop: ProductAnalyticsQueueOptions["onDrop"]; + #queue: QueuedEvent[] = []; + #timer: ReturnType | undefined; + #inFlight = false; + #dropped = 0; + + constructor(options: ProductAnalyticsQueueOptions) { + this.#sendBatch = options.sendBatch; + this.#isEnabled = options.isEnabled; + this.#batchSize = options.batchSize ?? PRODUCT_ANALYTICS_BATCH_SIZE; + this.#capacity = options.capacity ?? PRODUCT_ANALYTICS_QUEUE_CAPACITY; + this.#batchDelayMs = + options.batchDelayMs ?? PRODUCT_ANALYTICS_BATCH_DELAY_MS; + this.#retryDelayMs = + options.retryDelayMs ?? PRODUCT_ANALYTICS_RETRY_DELAY_MS; + this.#maxBatchBytes = + options.maxBatchBytes ?? PRODUCT_ANALYTICS_LIMITS.requestBytes; + this.#onDrop = options.onDrop; + } + + get size() { + return this.#queue.length; + } + + get dropped() { + return this.#dropped; + } + + enqueue(event: ProductEventInput) { + if (this.#queue.length >= this.#capacity) { + this.#queue.shift(); + this.#recordDrop(1); + } + + this.#queue.push({ event, attempts: 0 }); + if (this.#queue.length >= this.#batchSize) { + void this.flush(); + } else { + this.#schedule(this.#batchDelayMs); + } + } + + clear() { + if (this.#queue.length > 0) { + this.#recordDrop(this.#queue.length); + this.#queue = []; + } + this.#cancelTimer(); + } + + async flush() { + if (this.#inFlight || this.#queue.length === 0) return; + + this.#inFlight = true; + this.#cancelTimer(); + + try { + if (!(await this.#isEnabled())) { + this.clear(); + return; + } + + const batch = this.#takeBatch(); + if (batch.length === 0) return; + try { + await this.#sendBatch(batch.map(({ event }) => event)); + } catch { + if (!(await this.#isEnabled())) { + this.#recordDrop(batch.length); + return; + } + const retryable = batch + .filter(({ attempts }) => attempts === 0) + .map(({ event, attempts }) => ({ event, attempts: attempts + 1 })); + this.#recordDrop(batch.length - retryable.length); + const queued = [...retryable, ...this.#queue]; + const overflow = Math.max(0, queued.length - this.#capacity); + this.#recordDrop(overflow); + this.#queue = queued.slice(overflow); + if (this.#queue.length > 0) this.#schedule(this.#retryDelayMs); + return; + } + } finally { + this.#inFlight = false; + } + + if (this.#queue.length > 0) { + this.#schedule(this.#batchDelayMs); + } + } + + #schedule(delayMs: number) { + if (this.#timer !== undefined) return; + this.#timer = setTimeout(() => { + this.#timer = undefined; + void this.flush(); + }, delayMs); + } + + #cancelTimer() { + if (this.#timer === undefined) return; + clearTimeout(this.#timer); + this.#timer = undefined; + } + + #recordDrop(count: number) { + if (count === 0) return; + this.#dropped += count; + this.#onDrop?.(this.#dropped); + } + + #takeBatch() { + const batch: QueuedEvent[] = []; + + while (batch.length < this.#batchSize && this.#queue.length > 0) { + const next = this.#queue[0]; + if (!next) break; + const candidate = [...batch, next]; + const bytes = new TextEncoder().encode( + JSON.stringify({ events: candidate.map(({ event }) => event) }), + ).byteLength; + + if (bytes > this.#maxBatchBytes) { + if (batch.length > 0) break; + this.#queue.shift(); + this.#recordDrop(1); + continue; + } + + batch.push(next); + this.#queue.shift(); + } + + return batch; + } +} diff --git a/apps/web/__tests__/unit/developer-credits-webhook.test.ts b/apps/web/__tests__/unit/developer-credits-webhook.test.ts index 51362a9f728..b48513c126d 100644 --- a/apps/web/__tests__/unit/developer-credits-webhook.test.ts +++ b/apps/web/__tests__/unit/developer-credits-webhook.test.ts @@ -45,10 +45,6 @@ vi.mock("@cap/database/schema", () => ({ })); vi.mock("@cap/env", () => ({ - buildEnv: { - NEXT_PUBLIC_POSTHOG_KEY: "", - NEXT_PUBLIC_POSTHOG_HOST: "", - }, serverEnv: () => ({ STRIPE_WEBHOOK_SECRET: "whsec_test", }), @@ -86,13 +82,6 @@ vi.mock("drizzle-orm", () => ({ eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), })); -vi.mock("posthog-node", () => ({ - PostHog: vi.fn().mockImplementation(() => ({ - capture: vi.fn(), - shutdown: vi.fn().mockResolvedValue(undefined), - })), -})); - function makeWebhookRequest(body = "{}") { return new Request("https://cap.test/api/webhooks/stripe", { method: "POST", diff --git a/apps/web/__tests__/unit/guest-checkout-analytics.test.ts b/apps/web/__tests__/unit/guest-checkout-analytics.test.ts new file mode 100644 index 00000000000..9d87c0255ed --- /dev/null +++ b/apps/web/__tests__/unit/guest-checkout-analytics.test.ts @@ -0,0 +1,69 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createSession: vi.fn(), + product: vi.fn(), + readAnonymousId: vi.fn(), +})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ WEB_URL: "https://cap.so" }), +})); +vi.mock("@cap/utils", () => ({ + stripe: () => ({ + checkout: { sessions: { create: mocks.createSession } }, + }), +})); +vi.mock("@/lib/analytics/server", () => ({ + readAnalyticsAnonymousId: mocks.readAnonymousId, + scheduleServerProductEvent: mocks.product, +})); + +describe("guest checkout analytics", () => { + let POST: typeof import("@/app/api/settings/billing/guest-checkout/route").POST; + + beforeEach(async () => { + vi.clearAllMocks(); + mocks.createSession.mockResolvedValue({ + id: "cs_guest_1", + url: "https://checkout.stripe.com/session", + }); + POST = (await import("@/app/api/settings/billing/guest-checkout/route")) + .POST; + }); + + it("uses one stable fallback identity through checkout and purchase metadata", async () => { + mocks.readAnonymousId.mockReturnValue(undefined); + const response = await POST( + new NextRequest("https://cap.so/api/settings/billing/guest-checkout", { + method: "POST", + body: JSON.stringify({ priceId: "price_team", quantity: 3 }), + }), + ); + expect(response.status).toBe(200); + + const metadata = mocks.createSession.mock.calls[0]?.[0].metadata; + expect(metadata.analyticsAnonymousId).toMatch(/^guest:/); + expect(metadata.analyticsIsFirstPurchase).toBe("true"); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "checkout:cs_guest_1", + anonymousId: metadata.analyticsAnonymousId, + }), + ); + }); + + it("preserves an existing browser identity", async () => { + mocks.readAnonymousId.mockReturnValue("anonymous-browser-1"); + await POST( + new NextRequest("https://cap.so/api/settings/billing/guest-checkout", { + method: "POST", + body: JSON.stringify({ priceId: "price_team", quantity: 1 }), + }), + ); + expect( + mocks.createSession.mock.calls[0]?.[0].metadata.analyticsAnonymousId, + ).toBe("anonymous-browser-1"); + }); +}); diff --git a/apps/web/__tests__/unit/mobile-checkout.test.ts b/apps/web/__tests__/unit/mobile-checkout.test.ts index dee16ab7e58..17804ccc5d4 100644 --- a/apps/web/__tests__/unit/mobile-checkout.test.ts +++ b/apps/web/__tests__/unit/mobile-checkout.test.ts @@ -7,16 +7,12 @@ import { } from "@/lib/mobile-checkout"; const checkoutMocks = vi.hoisted(() => ({ - capture: vi.fn(), create: vi.fn(), - shutdown: vi.fn(() => Promise.resolve()), + product: vi.fn(), + readAnonymousId: vi.fn(), })); vi.mock("@cap/env", () => ({ - buildEnv: { - NEXT_PUBLIC_POSTHOG_HOST: "https://posthog.test", - NEXT_PUBLIC_POSTHOG_KEY: "test-key", - }, serverEnv: () => ({ WEB_URL: "https://cap.so" }), })); @@ -28,11 +24,9 @@ vi.mock("@cap/utils", () => ({ }), })); -vi.mock("posthog-node", () => ({ - PostHog: class { - capture = checkoutMocks.capture; - shutdown = checkoutMocks.shutdown; - }, +vi.mock("@/lib/analytics/server", () => ({ + readAnalyticsAnonymousId: checkoutMocks.readAnonymousId, + scheduleServerProductEvent: checkoutMocks.product, })); const makeGuestCheckoutRequest = (body: Record) => @@ -82,6 +76,8 @@ describe("checkout redirects", () => { metadata: { platform: "web", guestCheckout: "true", + analyticsIsFirstPurchase: "true", + analyticsAnonymousId: expect.stringMatching(/^guest:/), }, }); }); @@ -111,6 +107,8 @@ describe("checkout redirects", () => { metadata: { platform: "mobile", guestCheckout: "true", + analyticsIsFirstPurchase: "true", + analyticsAnonymousId: expect.stringMatching(/^guest:/), }, }), ); diff --git a/apps/web/__tests__/unit/product-analytics-browser-token.test.ts b/apps/web/__tests__/unit/product-analytics-browser-token.test.ts new file mode 100644 index 00000000000..941cbf7fb96 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-browser-token.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + createProductAnalyticsBrowserToken, + PRODUCT_ANALYTICS_BROWSER_TOKEN_COOKIE, + PRODUCT_ANALYTICS_BROWSER_TOKEN_TTL_SECONDS, + readProductAnalyticsBrowserToken, + readProductAnalyticsBrowserTokenClaims, + verifyProductAnalyticsBrowserToken, +} from "@/lib/analytics/browser-token"; + +const secret = "analytics-browser-token-test-secret"; +const now = Date.parse("2026-07-12T12:00:00.000Z"); + +describe("product analytics browser token", () => { + it("accepts an untampered token inside its bounded lifetime", () => { + const token = createProductAnalyticsBrowserToken( + secret, + "anonymous-1", + now, + ); + expect(verifyProductAnalyticsBrowserToken(token, secret, now)).toBe(true); + expect(readProductAnalyticsBrowserTokenClaims(token, secret, now)).toEqual({ + anonymousId: "anonymous-1", + }); + expect( + verifyProductAnalyticsBrowserToken( + token, + secret, + now + PRODUCT_ANALYTICS_BROWSER_TOKEN_TTL_SECONDS * 1000, + ), + ).toBe(true); + }); + + it("rejects expired, future, tampered, and malformed tokens", () => { + const token = createProductAnalyticsBrowserToken( + secret, + "anonymous-1", + now, + ); + expect( + verifyProductAnalyticsBrowserToken( + token, + secret, + now + (PRODUCT_ANALYTICS_BROWSER_TOKEN_TTL_SECONDS + 1) * 1000, + ), + ).toBe(false); + expect( + verifyProductAnalyticsBrowserToken( + createProductAnalyticsBrowserToken(secret, "anonymous-1", now + 61_000), + secret, + now, + ), + ).toBe(false); + expect(verifyProductAnalyticsBrowserToken(`${token}x`, secret, now)).toBe( + false, + ); + expect(verifyProductAnalyticsBrowserToken("invalid", secret, now)).toBe( + false, + ); + }); + + it("reads only the analytics token cookie", () => { + const token = createProductAnalyticsBrowserToken( + secret, + "anonymous-1", + now, + ); + expect( + readProductAnalyticsBrowserToken( + `other=value; ${PRODUCT_ANALYTICS_BROWSER_TOKEN_COOKIE}=${token}`, + ), + ).toBe(token); + expect(readProductAnalyticsBrowserToken("other=value")).toBeUndefined(); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-queue.test.ts b/apps/web/__tests__/unit/product-analytics-queue.test.ts new file mode 100644 index 00000000000..f79f2c6264b --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-queue.test.ts @@ -0,0 +1,377 @@ +import { + PRODUCT_ANALYTICS_LIMITS, + type ProductEventInput, +} from "@cap/analytics"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createProductEventId, + getOrCreateBrowserAnonymousId, + getOrCreateStorageId, + ProductAnalyticsQueue, + type ProductAnalyticsTransport, + readFirstTouchAttribution, + sendBrowserProductAnalytics, + shouldCaptureProductPageView, +} from "@/app/utils/product-analytics"; + +const makeEvent = (index: number): ProductEventInput => ({ + eventId: `event-${index}`, + eventName: "page_view", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "anonymous-1", + sessionId: "session-1", + platform: "web", +}); + +describe("ProductAnalyticsQueue", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("does not perform network work on enqueue", () => { + const transport = vi.fn(); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue(makeEvent(1)); + expect(transport).not.toHaveBeenCalled(); + }); + + it("flushes one batch after the interval", async () => { + const transport = vi + .fn() + .mockResolvedValue("success"); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue(makeEvent(1)); + queue.enqueue(makeEvent(2)); + + await vi.advanceTimersByTimeAsync(5_000); + expect(transport).toHaveBeenCalledTimes(1); + expect(transport.mock.calls[0]?.[0]).toHaveLength(2); + }); + + it("flushes immediately when a full batch is queued", async () => { + const transport = vi + .fn() + .mockResolvedValue("success"); + const queue = new ProductAnalyticsQueue(transport); + for (let i = 0; i < PRODUCT_ANALYTICS_LIMITS.batchSize; i += 1) { + queue.enqueue(makeEvent(i)); + } + await queue.flush(); + expect(transport).toHaveBeenCalledTimes(1); + expect(transport.mock.calls[0]?.[0]).toHaveLength( + PRODUCT_ANALYTICS_LIMITS.batchSize, + ); + }); + + it("allows only one request in flight", async () => { + let resolveTransport: ((value: "success") => void) | undefined; + const transport = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveTransport = resolve; + }), + ); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue(makeEvent(1)); + const first = queue.flush(); + const second = queue.flush(); + expect(first).toBe(second); + expect(transport).toHaveBeenCalledTimes(1); + resolveTransport?.("success"); + await first; + }); + + it("retries a failed batch once", async () => { + const transport = vi + .fn() + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("retry"); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue(makeEvent(1)); + await queue.flush(); + await vi.advanceTimersByTimeAsync(3_000); + expect(transport).toHaveBeenCalledTimes(2); + expect(queue.size).toBe(0); + }); + + it("honors retry backoff for a full failed batch", async () => { + const transport = vi + .fn() + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("success"); + const queue = new ProductAnalyticsQueue(transport); + for (let i = 0; i < PRODUCT_ANALYTICS_LIMITS.batchSize; i += 1) { + queue.enqueue(makeEvent(i)); + } + await Promise.resolve(); + await Promise.resolve(); + + expect(transport).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1_999); + expect(transport).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + expect(transport).toHaveBeenCalledTimes(2); + }); + + it("does not retry a rejected batch", async () => { + const transport = vi + .fn() + .mockResolvedValue("drop"); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue(makeEvent(1)); + await queue.flush(); + await vi.runAllTimersAsync(); + expect(transport).toHaveBeenCalledTimes(1); + }); + + it("bounds memory and drops the oldest queued events", async () => { + let resolveFirst: ((value: "success") => void) | undefined; + const transport = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValue("success"); + const queue = new ProductAnalyticsQueue(transport); + for (let i = 0; i < PRODUCT_ANALYTICS_LIMITS.queueSize + 30; i += 1) { + queue.enqueue(makeEvent(i)); + } + expect(queue.size).toBe(PRODUCT_ANALYTICS_LIMITS.queueSize); + expect(transport.mock.calls[0]?.[0][0]?.eventId).toBe("event-0"); + resolveFirst?.("success"); + await vi.waitFor(() => + expect(transport.mock.calls.length).toBeGreaterThanOrEqual(2), + ); + expect(transport.mock.calls[1]?.[0][0]?.eventId).toBe("event-30"); + }); + + it("keeps every request under the body size limit", async () => { + const requestSizes: number[] = []; + const transport = vi.fn(async (events) => { + requestSizes.push( + new TextEncoder().encode(JSON.stringify({ events })).byteLength, + ); + return "success"; + }); + const queue = new ProductAnalyticsQueue(transport); + for (let i = 0; i < 10; i += 1) { + queue.enqueue({ + ...makeEvent(i), + properties: { value: "x".repeat(20_000) }, + }); + } + + await vi.runAllTimersAsync(); + expect( + requestSizes.every( + (size) => size <= PRODUCT_ANALYTICS_LIMITS.requestBytes, + ), + ).toBe(true); + expect( + transport.mock.calls.reduce( + (count, [events]) => count + events.length, + 0, + ), + ).toBe(10); + }); + + it("drops a single event larger than the request limit", async () => { + const transport = vi.fn(); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue({ + ...makeEvent(1), + properties: { + value: "x".repeat(PRODUCT_ANALYTICS_LIMITS.requestBytes), + }, + }); + + await vi.runAllTimersAsync(); + expect(transport).not.toHaveBeenCalled(); + expect(queue.size).toBe(0); + }); +}); + +describe("browser analytics identity", () => { + it("falls back when secure UUID generation is unavailable", () => { + const randomValues = (values: Uint32Array) => { + values.set([123, 456]); + return values; + }; + expect(createProductEventId(null, 1_000, randomValues)).toBe( + "fallback-rs-3f-co", + ); + expect( + createProductEventId( + () => { + throw new Error("blocked"); + }, + 1_000, + randomValues, + ), + ).toBe("fallback-rs-3f-co"); + expect(createProductEventId(null, 1_000, null)).toMatch( + /^fallback-rs-counter-[a-z0-9]+$/, + ); + }); + + it("reuses a persisted identifier", () => { + const storage = { + getItem: vi.fn(() => "existing-id"), + setItem: vi.fn(), + }; + expect(getOrCreateStorageId(storage, "key", () => "new-id")).toBe( + "existing-id", + ); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it("creates and persists an identifier once", () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn() }; + expect(getOrCreateStorageId(storage, "key", () => "new-id")).toBe("new-id"); + expect(storage.setItem).toHaveBeenCalledWith("key", "new-id"); + }); + + it("uses the server-issued cookie identity", () => { + const storage = { getItem: vi.fn(() => "stale-id"), setItem: vi.fn() }; + expect( + getOrCreateBrowserAnonymousId(storage, "signed-id", () => "new-id"), + ).toBe("signed-id"); + expect(storage.setItem).toHaveBeenCalledWith( + "cap_analytics_anonymous_id_v1", + "signed-id", + ); + }); + + it("falls back when storage is unavailable", () => { + const storage = { + getItem: vi.fn(() => { + throw new Error("blocked"); + }), + setItem: vi.fn(), + }; + expect(getOrCreateStorageId(storage, "key", () => "memory-id")).toBe( + "memory-id", + ); + }); + + it("keeps one generated id when persistence is unavailable", () => { + const createId = vi.fn(() => "memory-id"); + const storage = { + getItem: vi.fn(() => null), + setItem: vi.fn(() => { + throw new Error("blocked"); + }), + }; + expect(getOrCreateStorageId(storage, "key", createId)).toBe("memory-id"); + expect(createId).toHaveBeenCalledOnce(); + }); +}); + +describe("first-touch attribution", () => { + it("stores only allowlisted attribution fields", () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn() }; + const result = readFirstTouchAttribution( + "?utm_source=google&utm_campaign=launch&email=private%40example.com", + storage, + ); + expect(result).toEqual({ utm_source: "google", utm_campaign: "launch" }); + expect(storage.setItem).toHaveBeenCalledOnce(); + }); + + it("does not overwrite existing attribution", () => { + const storage = { + getItem: vi.fn(() => '{"utm_source":"original"}'), + setItem: vi.fn(), + }; + expect(readFirstTouchAttribution("?utm_source=new", storage)).toEqual({ + utm_source: "original", + }); + expect(storage.setItem).not.toHaveBeenCalled(); + }); +}); + +describe("product page views", () => { + it.each(["/", "/pricing", "/dashboard", "/dashboard/settings"])( + "captures %s", + (pathname) => { + expect(shouldCaptureProductPageView(pathname)).toBe(true); + }, + ); + + it.each(["/s/video-id", "/c/comment-id", "/embed/video-id"])( + "excludes high-volume viewer route %s", + (pathname) => { + expect(shouldCaptureProductPageView(pathname)).toBe(false); + }, + ); +}); + +describe("browser product analytics transport", () => { + it("uses a beacon during unload without also fetching", async () => { + const fetchImpl = vi.fn(); + const sendBeacon = vi.fn(() => true); + await expect( + sendBrowserProductAnalytics([makeEvent(1)], "unload", { + fetchImpl, + sendBeacon, + }), + ).resolves.toBe("success"); + expect(sendBeacon).toHaveBeenCalledOnce(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("falls back to keepalive fetch when a beacon is rejected", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response(null, { status: 202 })); + await expect( + sendBrowserProductAnalytics([makeEvent(1)], "unload", { + fetchImpl, + sendBeacon: () => false, + }), + ).resolves.toBe("success"); + expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ keepalive: true }); + }); + + it.each([ + [429, "retry"], + [503, "retry"], + [400, "drop"], + ] as const)("maps HTTP %s to %s", async (status, result) => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response(null, { status })); + await expect( + sendBrowserProductAnalytics([makeEvent(1)], "normal", { fetchImpl }), + ).resolves.toBe(result); + }); + + it("retries transport failures", async () => { + const fetchImpl = vi + .fn() + .mockRejectedValue(new Error("offline")); + await expect( + sendBrowserProductAnalytics([makeEvent(1)], "normal", { fetchImpl }), + ).resolves.toBe("retry"); + }); + + it("times out a stalled request", async () => { + vi.useFakeTimers(); + const fetchImpl = vi.fn( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ); + }), + ); + const result = sendBrowserProductAnalytics([makeEvent(1)], "normal", { + fetchImpl, + }); + await vi.advanceTimersByTimeAsync(3_000); + await expect(result).resolves.toBe("retry"); + vi.useRealTimers(); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-request.test.ts b/apps/web/__tests__/unit/product-analytics-request.test.ts new file mode 100644 index 00000000000..59ba91ba862 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-request.test.ts @@ -0,0 +1,239 @@ +import { + PRODUCT_ANALYTICS_LIMITS, + type ProductEventInput, +} from "@cap/analytics"; +import { describe, expect, it } from "vitest"; +import { + getProductAnalyticsRateLimitKey, + hasExpectedBrowserAnalyticsMetadata, + isAllowedAnonymousBrowserProductEvent, + isAuthenticatedAnalyticsRequestCandidate, + normalizeGeoHeader, + normalizeProductEventBatch, + ProductAnalyticsRateLimiter, +} from "@/lib/analytics/request"; + +const allowedOrigins = ["https://cap.so", "tauri://localhost"]; +const event: ProductEventInput = { + eventId: "event-1", + eventName: "page_view", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "anonymous-1", + sessionId: "session-1", + platform: "web", +}; +const now = Date.parse("2026-07-12T12:00:01.000Z"); + +describe("hasExpectedBrowserAnalyticsMetadata", () => { + it.each([ + [ + "same-origin browser", + { origin: "https://cap.so", secFetchSite: "same-origin" }, + ], + [ + "same-site browser", + { origin: "https://cap.so", secFetchSite: "same-site" }, + ], + ])("accepts %s", (_label, headers) => { + expect(hasExpectedBrowserAnalyticsMetadata(headers, allowedOrigins)).toBe( + true, + ); + }); + + it("rejects cross-site browser requests", () => { + expect( + hasExpectedBrowserAnalyticsMetadata( + { origin: "https://attacker.example", secFetchSite: "cross-site" }, + allowedOrigins, + ), + ).toBe(false); + }); + + it("rejects requests without browser metadata", () => { + expect(hasExpectedBrowserAnalyticsMetadata({}, allowedOrigins)).toBe(false); + expect( + hasExpectedBrowserAnalyticsMetadata( + { origin: "https://cap.so" }, + allowedOrigins, + ), + ).toBe(false); + expect( + hasExpectedBrowserAnalyticsMetadata( + { origin: "https://cap.so", secFetchSite: "none" }, + allowedOrigins, + ), + ).toBe(false); + }); + + it("allows API-key requests to attempt actor resolution", () => { + expect( + isAuthenticatedAnalyticsRequestCandidate({ + authorization: `Bearer ${"a".repeat(36)}`, + }), + ).toBe(true); + expect( + isAuthenticatedAnalyticsRequestCandidate({ + authorization: "Bearer invalid", + }), + ).toBe(false); + expect( + isAuthenticatedAnalyticsRequestCandidate({ + authorization: `Bearer ${"a".repeat(36)} extra`, + }), + ).toBe(false); + expect( + isAuthenticatedAnalyticsRequestCandidate({ + authorization: `Bearer ${"a".repeat(36)}`, + origin: "https://attacker.example", + }), + ).toBe(true); + }); + + it("allows only bounded top-of-funnel web events without an actor", () => { + expect(isAllowedAnonymousBrowserProductEvent(event, "anonymous-1")).toBe( + true, + ); + expect( + isAllowedAnonymousBrowserProductEvent( + { ...event, eventName: "recording_started" }, + "anonymous-1", + ), + ).toBe(false); + expect( + isAllowedAnonymousBrowserProductEvent( + { ...event, anonymousId: "attacker-chosen" }, + "anonymous-1", + ), + ).toBe(false); + expect( + isAllowedAnonymousBrowserProductEvent( + { ...event, platform: "desktop" }, + "anonymous-1", + ), + ).toBe(false); + }); + + it("rejects oversized declared bodies", () => { + expect( + hasExpectedBrowserAnalyticsMetadata( + { contentLength: String(PRODUCT_ANALYTICS_LIMITS.requestBytes + 1) }, + allowedOrigins, + ), + ).toBe(false); + }); + + it.each(["invalid", "-1", "1.5"])( + "rejects malformed content length %s", + (contentLength) => { + expect( + hasExpectedBrowserAnalyticsMetadata({ contentLength }, allowedOrigins), + ).toBe(false); + }, + ); +}); + +describe("normalizeProductEventBatch", () => { + it("accepts a bounded valid batch", () => { + expect(normalizeProductEventBatch([event], now)).toEqual([event]); + }); + + it("rejects an empty batch", () => { + expect(normalizeProductEventBatch([], now)).toBeNull(); + }); + + it("rejects a batch above the cap", () => { + expect( + normalizeProductEventBatch( + Array.from( + { length: PRODUCT_ANALYTICS_LIMITS.batchSize + 1 }, + () => event, + ), + now, + ), + ).toBeNull(); + }); + + it("rejects the whole batch when one event is invalid", () => { + expect( + normalizeProductEventBatch( + [event, { ...event, eventName: "$autocapture" }], + now, + ), + ).toBeNull(); + }); + + it("rejects an undeclared oversized body", () => { + expect( + normalizeProductEventBatch( + [ + { + ...event, + properties: { + value: "x".repeat(PRODUCT_ANALYTICS_LIMITS.requestBytes), + }, + }, + ], + now, + ), + ).toBeNull(); + }); + + it.each([ + "user_signed_up", + "checkout_started", + "guest_checkout_started", + "purchase_completed", + ] as const)("rejects client-authored %s", (eventName) => { + expect( + normalizeProductEventBatch([{ ...event, eventName }], now), + ).toBeNull(); + }); +}); + +describe("ProductAnalyticsRateLimiter", () => { + it("enforces per-key and process-wide fallback limits", () => { + const limiter = new ProductAnalyticsRateLimiter({ + perKeyLimit: 2, + globalLimit: 4, + windowMs: 1_000, + }); + expect(limiter.isRateLimited("a", 0)).toBe(false); + expect(limiter.isRateLimited("a", 0)).toBe(false); + expect(limiter.isRateLimited("a", 0)).toBe(true); + expect(limiter.isRateLimited("b", 0)).toBe(false); + expect(limiter.isRateLimited("c", 0)).toBe(true); + expect(limiter.isRateLimited("a", 1_000)).toBe(false); + }); + + it("uses only a platform-owned Vercel request identity", () => { + expect( + getProductAnalyticsRateLimitKey({ + trustedVercelProxy: true, + xVercelForwardedFor: "203.0.113.10, 10.0.0.1", + }), + ).toBe("203.0.113.10"); + expect( + getProductAnalyticsRateLimitKey({ + trustedVercelProxy: false, + xVercelForwardedFor: "attacker-controlled", + }), + ).toBe("self-hosted"); + expect(getProductAnalyticsRateLimitKey({ trustedVercelProxy: true })).toBe( + "vercel-unknown", + ); + }); +}); + +describe("normalizeGeoHeader", () => { + it("decodes and bounds a city header", () => { + expect(normalizeGeoHeader("Nicosia%20Centre", true)).toBe("Nicosia Centre"); + }); + + it("rejects malformed encoded data", () => { + expect(normalizeGeoHeader("%E0%A4%A", true)).toBeUndefined(); + }); + + it("removes unknown values", () => { + expect(normalizeGeoHeader("unknown")).toBeUndefined(); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-scheduler.test.ts b/apps/web/__tests__/unit/product-analytics-scheduler.test.ts new file mode 100644 index 00000000000..a089b72a7a7 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-scheduler.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + after: vi.fn(() => { + throw new Error("after unavailable"); + }), +})); + +vi.mock("next/server", () => ({ after: mocks.after })); +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ + PRODUCT_ANALYTICS_TINYBIRD_HOST: undefined, + PRODUCT_ANALYTICS_TINYBIRD_TOKEN: undefined, + }), +})); + +describe("analytics scheduling", () => { + afterEach(() => vi.restoreAllMocks()); + + it("cannot make a business route fail when after is unavailable", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { scheduleServerProductEvent } = await import( + "@/lib/analytics/server" + ); + + expect(() => + scheduleServerProductEvent({ + eventId: "checkout:cs_1", + eventName: "checkout_started", + anonymousId: "anonymous-1", + platform: "web", + }), + ).not.toThrow(); + await Promise.resolve(); + expect(mocks.after).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-server.test.ts b/apps/web/__tests__/unit/product-analytics-server.test.ts new file mode 100644 index 00000000000..f1dda108db9 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-server.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { createServerProductEventRows } from "@/lib/analytics/server-event"; + +describe("server product analytics", () => { + it("builds a deterministic trusted server event", () => { + const [row] = createServerProductEventRows({ + eventId: "stripe:evt_123:purchase_completed", + eventName: "purchase_completed", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "anonymous-1", + platform: "web", + userId: "user-1", + organizationId: "org-1", + properties: { + quantity: 3, + email: "private@example.com", + nested: { private: true }, + }, + }); + + expect(row).toMatchObject({ + event_id: "stripe:evt_123:purchase_completed", + event_name: "purchase_completed", + source: "server", + platform: "web", + anonymous_id: "anonymous-1", + user_id: "user-1", + organization_id: "org-1", + properties: '{"quantity":3}', + }); + }); + + it("uses an authenticated fallback identity", () => { + const [row] = createServerProductEventRows({ + eventId: "signup:user-1", + eventName: "user_signed_up", + platform: "server", + userId: "user-1", + }); + expect(row?.anonymous_id).toBe("user:user-1"); + }); + + it("drops an event without any stable identity", () => { + expect( + createServerProductEventRows({ + eventId: "event-1", + eventName: "page_view", + platform: "server", + }), + ).toEqual([]); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-transport.test.ts b/apps/web/__tests__/unit/product-analytics-transport.test.ts new file mode 100644 index 00000000000..2fa585cbe32 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-transport.test.ts @@ -0,0 +1,134 @@ +import { + createProductEventRows, + type ProductAnalyticsError, + sendProductAnalyticsRows, +} from "@cap/analytics"; +import { hasAnalyticsSessionCookie } from "@cap/web-backend"; +import { describe, expect, it, vi } from "vitest"; + +const rows = createProductEventRows( + [ + { + eventId: "event-1", + eventName: "page_view", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "anonymous-1", + platform: "web", + }, + ], + { + receivedAt: "2026-07-12T12:00:01.000Z", + source: "client", + }, +); + +describe("Tinybird product event transport", () => { + it("skips session resolution for anonymous requests", () => { + expect(hasAnalyticsSessionCookie()).toBe(false); + expect(hasAnalyticsSessionCookie("theme=dark; visitor=123")).toBe(false); + expect( + hasAnalyticsSessionCookie( + "theme=dark; next-auth.session-token=token; visitor=123", + ), + ).toBe(true); + expect(hasAnalyticsSessionCookie("next-auth.session-token.0=chunk")).toBe( + true, + ); + }); + + it("posts NDJSON with append-only credentials", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response(null, { status: 202 })); + + await sendProductAnalyticsRows({ + host: "https://api.tinybird.co", + token: "append-token", + rows, + fetchImpl, + }); + + const [url, request] = fetchImpl.mock.calls[0] ?? []; + expect(String(url)).toBe( + "https://api.tinybird.co/v0/events?name=product_events_v1&format=ndjson", + ); + expect(request).toMatchObject({ + method: "POST", + headers: { + Authorization: "Bearer append-token", + "Content-Type": "application/x-ndjson", + }, + body: JSON.stringify(rows[0]), + }); + }); + + it("retries a transient response once", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response("busy", { status: 503 })) + .mockResolvedValueOnce(new Response(null, { status: 202 })); + + await sendProductAnalyticsRows({ + host: "https://api.tinybird.co", + token: "append-token", + rows, + fetchImpl, + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("does not retry a permanent response", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response("invalid", { status: 400 })); + + await expect( + sendProductAnalyticsRows({ + host: "https://api.tinybird.co", + token: "append-token", + rows, + fetchImpl, + }), + ).rejects.toMatchObject({ + _tag: "ProductAnalyticsError", + retryable: false, + status: 400, + } satisfies Partial); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it("supports a single-attempt collector path", async () => { + const fetchImpl = vi + .fn() + .mockRejectedValue(new Error("offline")); + await expect( + sendProductAnalyticsRows({ + host: "https://api.tinybird.co", + token: "append-token", + rows, + maxAttempts: 1, + fetchImpl, + }), + ).rejects.toMatchObject({ retryable: true }); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it("fails after two network attempts", async () => { + const fetchImpl = vi + .fn() + .mockRejectedValue(new Error("offline")); + + await expect( + sendProductAnalyticsRows({ + host: "https://api.tinybird.co", + token: "append-token", + rows, + fetchImpl, + }), + ).rejects.toMatchObject({ + _tag: "ProductAnalyticsError", + retryable: true, + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/__tests__/unit/subscription-analytics-webhook.test.ts b/apps/web/__tests__/unit/subscription-analytics-webhook.test.ts new file mode 100644 index 00000000000..d2bc88d18e0 --- /dev/null +++ b/apps/web/__tests__/unit/subscription-analytics-webhook.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + product: vi.fn(), + constructEvent: vi.fn(), + retrieveCustomer: vi.fn(), + retrieveSubscription: vi.fn(), +})); + +const dbChain = { + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + update: vi.fn(), + set: vi.fn(), +}; + +vi.mock("@/lib/analytics/server", () => ({ + scheduleServerProductEvent: mocks.product, +})); +vi.mock("@/lib/developer-credits", () => ({ addCreditsToAccount: vi.fn() })); +vi.mock("@cap/database", () => ({ db: () => dbChain })); +vi.mock("@cap/database/helpers", () => ({ nanoId: () => "new-user" })); +vi.mock("@cap/database/schema", () => ({ + developerCreditTransactions: {}, + users: { + id: "id", + email: "email", + }, +})); +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ STRIPE_WEBHOOK_SECRET: "whsec_test" }), +})); +vi.mock("@cap/utils", () => ({ + stripe: () => ({ + webhooks: { constructEvent: mocks.constructEvent }, + customers: { + retrieve: mocks.retrieveCustomer, + update: vi.fn(), + }, + subscriptions: { + retrieve: mocks.retrieveSubscription, + list: vi.fn(), + }, + }), +})); +vi.mock("@cap/web-domain", () => ({ + Organisation: { OrganisationId: { make: (value: string) => value } }, + User: { UserId: { make: (value: string) => value } }, +})); +vi.mock("drizzle-orm", () => ({ + and: (...args: unknown[]) => args, + eq: (left: unknown, right: unknown) => ({ left, right }), +})); + +const dbUser = { + id: "user-1", + email: "user@example.com", + activeOrganizationId: "org-1", + stripeSubscriptionId: null, + name: "User", +}; + +const customer = { + id: "cus_1", + deleted: false, + email: "user@example.com", + metadata: { userId: "user-1" }, +}; + +const subscription = { + id: "sub_1", + status: "active", + items: { + data: [ + { + quantity: 3, + price: { + id: "price_team", + unit_amount: 900, + recurring: { interval: "month", interval_count: 1 }, + }, + }, + ], + }, +}; + +function session(overrides: Record = {}) { + return { + id: "cs_1", + customer: "cus_1", + subscription: "sub_1", + payment_status: "paid", + amount_total: 2700, + amount_subtotal: 3000, + currency: "usd", + total_details: { amount_discount: 300 }, + metadata: { + platform: "web", + analyticsAnonymousId: "anonymous-1", + analyticsIsFirstPurchase: "true", + }, + ...overrides, + }; +} + +function request() { + return new Request("https://cap.so/api/webhooks/stripe", { + method: "POST", + headers: { "Stripe-Signature": "signature" }, + body: "{}", + }); +} + +function event(type: string, checkoutSession: ReturnType) { + return { + id: `evt_${type}`, + created: 1_752_537_600, + type, + data: { object: checkoutSession }, + }; +} + +describe("Stripe subscription analytics", () => { + let POST: typeof import("@/app/api/webhooks/stripe/route").POST; + + beforeEach(async () => { + vi.clearAllMocks(); + dbChain.select.mockReturnValue(dbChain); + dbChain.from.mockReturnValue(dbChain); + dbChain.where.mockReturnValue(dbChain); + dbChain.limit.mockResolvedValue([dbUser]); + dbChain.update.mockReturnValue(dbChain); + dbChain.set.mockReturnValue(dbChain); + mocks.retrieveCustomer.mockResolvedValue(customer); + mocks.retrieveSubscription.mockResolvedValue(subscription); + POST = (await import("@/app/api/webhooks/stripe/route")).POST; + }); + + it("emits a paid purchase with revenue dimensions and deterministic IDs", async () => { + mocks.constructEvent.mockReturnValue( + event("checkout.session.completed", session()), + ); + expect((await POST(request())).status).toBe(200); + + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "stripe:evt_checkout.session.completed:purchase_completed", + eventName: "purchase_completed", + occurredAt: "2025-07-15T00:00:00.000Z", + anonymousId: "anonymous-1", + userId: "user-1", + organizationId: "org-1", + properties: expect.objectContaining({ + payment_status: "paid", + amount_total_minor: 2700, + currency: "usd", + unit_amount_minor: 900, + billing_interval: "month", + quantity: 3, + }), + }), + ); + }); + + it("keeps first-purchase attribution stable on duplicate delivery", async () => { + dbChain.limit.mockResolvedValue([ + { ...dbUser, stripeSubscriptionId: "sub_1" }, + ]); + mocks.constructEvent.mockReturnValue( + event("checkout.session.completed", session()), + ); + + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + is_first_purchase: true, + }), + }), + ); + }); + + it("does not count an unpaid checkout as a purchase", async () => { + mocks.constructEvent.mockReturnValue( + event( + "checkout.session.completed", + session({ payment_status: "unpaid" }), + ), + ); + expect((await POST(request())).status).toBe(200); + expect(mocks.product).not.toHaveBeenCalled(); + }); + + it("emits when an asynchronous subscription payment settles", async () => { + mocks.constructEvent.mockReturnValue( + event("checkout.session.async_payment_succeeded", session()), + ); + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: + "stripe:evt_checkout.session.async_payment_succeeded:purchase_completed", + userId: "user-1", + }), + ); + }); + + it("counts a no-payment trial while exposing its zero revenue", async () => { + mocks.retrieveSubscription.mockResolvedValue({ + ...subscription, + status: "trialing", + }); + mocks.constructEvent.mockReturnValue( + event( + "checkout.session.completed", + session({ + payment_status: "no_payment_required", + amount_total: 0, + }), + ), + ); + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + payment_status: "no_payment_required", + amount_total_minor: 0, + subscription_status: "trialing", + }), + }), + ); + }); +}); diff --git a/apps/web/actions/analytics/track-user-signed-up.ts b/apps/web/actions/analytics/track-user-signed-up.ts index 2ffb33c6f0a..ce0af3d7b3c 100644 --- a/apps/web/actions/analytics/track-user-signed-up.ts +++ b/apps/web/actions/analytics/track-user-signed-up.ts @@ -1,9 +1,16 @@ "use server"; +import { PRODUCT_ANALYTICS_ANONYMOUS_ID_COOKIE } from "@cap/analytics"; import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { users } from "@cap/database/schema"; import { sql } from "drizzle-orm"; +import { cookies } from "next/headers"; +import { + captureServerProductEvent, + scheduleAfterResponse, +} from "@/lib/analytics/server"; +import { normalizeServerIdentifier } from "@/lib/analytics/server-event"; const SIGNUP_TRACKING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; @@ -15,6 +22,7 @@ type UserPreferences = { pauseReactions: boolean; }; trackedEvents?: { + product_user_signed_up?: boolean; user_signed_up?: boolean; }; } | null; @@ -53,10 +61,9 @@ export async function checkAndMarkUserSignedUpTracked(): Promise<{ try { const prefs = currentUser.preferences as UserPreferences; const alreadyTracked = Boolean(prefs?.trackedEvents?.user_signed_up); - - if (alreadyTracked) { - return { shouldTrack: false }; - } + const productAlreadyTracked = Boolean( + prefs?.trackedEvents?.product_user_signed_up, + ); const createdAtTime = getCreatedAtTime(currentUser.created_at); @@ -67,6 +74,41 @@ export async function checkAndMarkUserSignedUpTracked(): Promise<{ return { shouldTrack: false }; } + if (!productAlreadyTracked) { + const analyticsAnonymousId = normalizeServerIdentifier( + (await cookies()).get(PRODUCT_ANALYTICS_ANONYMOUS_ID_COOKIE)?.value, + ); + scheduleAfterResponse(async () => { + try { + const captured = await captureServerProductEvent({ + eventId: `signup:${currentUser.id}`, + eventName: "user_signed_up", + occurredAt: new Date(createdAtTime).toISOString(), + anonymousId: analyticsAnonymousId, + platform: "web", + userId: currentUser.id, + organizationId: currentUser.activeOrganizationId, + }); + if (!captured) return; + + await db() + .update(users) + .set({ + preferences: sql`JSON_SET(COALESCE(${users.preferences}, JSON_OBJECT()), '$.trackedEvents.product_user_signed_up', true)`, + }) + .where( + sql`(${users.id} = ${currentUser.id}) AND JSON_CONTAINS(COALESCE(${users.preferences}, JSON_OBJECT()), CAST(true AS JSON), '$.trackedEvents.product_user_signed_up') = 0`, + ); + } catch (error) { + console.error("Failed to capture user_signed_up", error); + } + }); + } + + if (alreadyTracked) { + return { shouldTrack: false }; + } + const result = await db() .update(users) .set({ diff --git a/apps/web/actions/organization/send-invites.ts b/apps/web/actions/organization/send-invites.ts index ad3642d4e3b..b46f8e5193b 100644 --- a/apps/web/actions/organization/send-invites.ts +++ b/apps/web/actions/organization/send-invites.ts @@ -15,6 +15,7 @@ import { serverEnv } from "@cap/env"; import type { Organisation } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { scheduleServerProductEvent } from "@/lib/analytics/server"; import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; import { type AssignableOrganizationRole, @@ -211,6 +212,31 @@ export async function sendOrganizationInvites( } } + const failedInviteIds = new Set(failedInvites.map((invite) => invite.id)); + const successfulInvites = inviteRecords.filter( + (invite) => !failedInviteIds.has(invite.id), + ); + const firstSuccessfulInvite = successfulInvites[0]; + if (firstSuccessfulInvite) { + scheduleServerProductEvent({ + eventId: `organization_invites:${firstSuccessfulInvite.id}`, + eventName: "organization_invite_sent", + platform: "web", + userId: user.id, + organizationId, + properties: { + invite_count: successfulInvites.length, + admin_count: successfulInvites.filter( + (invite) => invite.role === "admin", + ).length, + member_count: successfulInvites.filter( + (invite) => invite.role === "member", + ).length, + delivery: "email", + }, + }); + } + revalidatePath("/dashboard/settings/organization"); return { success: true, failedEmails }; diff --git a/apps/web/actions/organization/update-seat-quantity.ts b/apps/web/actions/organization/update-seat-quantity.ts index 942bbb0bf08..89f365161a1 100644 --- a/apps/web/actions/organization/update-seat-quantity.ts +++ b/apps/web/actions/organization/update-seat-quantity.ts @@ -11,6 +11,7 @@ import { stripe } from "@cap/utils"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { scheduleServerProductEvent } from "@/lib/analytics/server"; import { calculateProSeats } from "@/utils/organization"; async function getOwnerSubscription( @@ -205,6 +206,27 @@ export async function updateSeatQuantity( } revalidatePath("/dashboard/settings/organization"); + const latestInvoice = + typeof updatedSubscription.latest_invoice === "string" + ? updatedSubscription.latest_invoice + : updatedSubscription.latest_invoice?.id; + scheduleServerProductEvent({ + eventId: `seat_quantity:${subscription.id}:${latestInvoice ?? updatedSubscription.current_period_start}:${newQuantity}`, + eventName: "seat_quantity_changed", + platform: "web", + userId: user.id, + organizationId, + properties: { + previous_quantity: currentQuantity, + new_quantity: newQuantity, + quantity_delta: newQuantity - currentQuantity, + direction: isSeatIncrease ? "increase" : "decrease", + price_id: subscriptionItem.price.id, + unit_amount_minor: subscriptionItem.price.unit_amount, + currency: subscriptionItem.price.currency, + billing_interval: subscriptionItem.price.recurring?.interval, + }, + }); return { success: true, newQuantity }; } diff --git a/apps/web/app/Layout/AppProviders.tsx b/apps/web/app/Layout/AppProviders.tsx index 520510c5f6d..f4dff23e88d 100644 --- a/apps/web/app/Layout/AppProviders.tsx +++ b/apps/web/app/Layout/AppProviders.tsx @@ -4,23 +4,17 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { type PropsWithChildren, Suspense } from "react"; import { SonnerToaster } from "@/components/SonnerToastProvider"; import { runPromise } from "@/lib/server"; -import { getBootstrapData } from "@/utils/getBootstrapData"; import { PublicEnvContext } from "@/utils/public-env"; import { AuthContextProvider } from "./AuthContext"; import { resolveCurrentUser } from "./current-user"; import { GTag } from "./GTag"; import { MetaPixel } from "./MetaPixel"; -import { PosthogIdentify } from "./PosthogIdentify"; import { PurchaseTracker } from "./PurchaseTracker"; -import { - PostHogProvider, - ReactQueryProvider, - SessionProvider, -} from "./providers"; +import { ReactQueryProvider, SessionProvider } from "./providers"; +import { SignupAnalytics } from "./SignupAnalytics"; import { StripeContextProvider } from "./StripeContext"; export async function AppProviders({ children }: PropsWithChildren) { - const bootstrapData = await getBootstrapData(); const plans = serverEnv().VERCEL_ENV === "production" ? STRIPE_PLAN_IDS.production @@ -28,32 +22,30 @@ export async function AppProviders({ children }: PropsWithChildren) { return ( - - - - - - - - {children} - - - - - - - - - - - - + + + + + + + {children} + + + + + + + + + + + ); } diff --git a/apps/web/app/Layout/PosthogIdentify.tsx b/apps/web/app/Layout/PosthogIdentify.tsx deleted file mode 100644 index 851143c9a4d..00000000000 --- a/apps/web/app/Layout/PosthogIdentify.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { Suspense, useEffect } from "react"; -import { checkAndMarkUserSignedUpTracked } from "@/actions/analytics/track-user-signed-up"; -import { - identifyUser, - initAnonymousUser, - trackEvent, -} from "../utils/analytics"; -import { useCurrentUser } from "./AuthContext"; - -export function PosthogIdentify() { - return ( - - - - ); -} - -function Inner() { - const user = useCurrentUser(); - - useEffect(() => { - if (!user) { - initAnonymousUser(); - return; - } else { - identifyUser(user.id); - - (async () => { - const { shouldTrack } = await checkAndMarkUserSignedUpTracked(); - if (shouldTrack) { - trackEvent("user_signed_up"); - } - trackEvent("user_signed_in"); - })(); - } - }, [user]); - - return null; -} diff --git a/apps/web/app/Layout/PosthogPageView.tsx b/apps/web/app/Layout/PosthogPageView.tsx deleted file mode 100644 index 41996db03bb..00000000000 --- a/apps/web/app/Layout/PosthogPageView.tsx +++ /dev/null @@ -1,50 +0,0 @@ -// app/PostHogPageView.tsx -"use client"; - -import { usePathname, useSearchParams } from "next/navigation"; -import { usePostHog } from "posthog-js/react"; -import { Suspense, useEffect, useMemo } from "react"; - -let lastTrackedUrl: string | null = null; - -function PostHogPageView(): null { - const pathname = usePathname(); - const searchParams = useSearchParams(); - const posthog = usePostHog(); - const search = useMemo(() => searchParams?.toString() ?? "", [searchParams]); - - useEffect(() => { - if (!pathname || !posthog) { - return; - } - - try { - let url = window.location.origin + pathname; - if (search) { - url = `${url}?${search}`; - } - - if (lastTrackedUrl === url) { - return; - } - - posthog.capture("$pageview", { $current_url: url }); - lastTrackedUrl = url; - } catch (error) { - console.error("Error capturing pageview:", error); - } - }, [pathname, search, posthog]); - - return null; -} - -// Wrap this in Suspense to avoid the `useSearchParams` usage above -// from de-opting the whole app into client-side rendering -// See: https://nextjs.org/docs/messages/deopted-into-client-rendering -export default function SuspendedPostHogPageView() { - return ( - - - - ); -} diff --git a/apps/web/app/Layout/ProductAnalyticsPageView.tsx b/apps/web/app/Layout/ProductAnalyticsPageView.tsx new file mode 100644 index 00000000000..5bccfd96b9d --- /dev/null +++ b/apps/web/app/Layout/ProductAnalyticsPageView.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; +import { + captureProductPageView, + shouldCaptureProductPageView, +} from "../utils/product-analytics"; + +let lastCapturedPathname: string | undefined; + +export function ProductAnalyticsPageView() { + const pathname = usePathname(); + + useEffect(() => { + if ( + !pathname || + pathname === lastCapturedPathname || + !shouldCaptureProductPageView(pathname) + ) { + return; + } + + lastCapturedPathname = pathname; + captureProductPageView(); + }, [pathname]); + + return null; +} diff --git a/apps/web/app/Layout/SignupAnalytics.tsx b/apps/web/app/Layout/SignupAnalytics.tsx new file mode 100644 index 00000000000..c751ad19b56 --- /dev/null +++ b/apps/web/app/Layout/SignupAnalytics.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Suspense, useEffect } from "react"; +import { checkAndMarkUserSignedUpTracked } from "@/actions/analytics/track-user-signed-up"; +import { trackEvent } from "../utils/analytics"; +import { useCurrentUser } from "./AuthContext"; + +export function SignupAnalytics() { + return ( + + + + ); +} + +function Inner() { + const user = useCurrentUser(); + + useEffect(() => { + if (!user) return; + + void checkAndMarkUserSignedUpTracked().then(({ shouldTrack }) => { + if (shouldTrack) trackEvent("user_signed_up"); + }); + }, [user]); + + return null; +} diff --git a/apps/web/app/Layout/providers.tsx b/apps/web/app/Layout/providers.tsx index 79ff4a0675e..9f20885f857 100644 --- a/apps/web/app/Layout/providers.tsx +++ b/apps/web/app/Layout/providers.tsx @@ -1,6 +1,5 @@ "use client"; -import { buildEnv } from "@cap/env"; import { TanStackDevtools, type TanStackDevtoolsReactInit, @@ -11,118 +10,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; -import type { PostHogConfig } from "posthog-js"; -import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"; -import { - type PropsWithChildren, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import type { BootstrapData } from "@/utils/getBootstrapData"; - -import PostHogPageView from "./PosthogPageView"; - -type CapPostHogConfig = Partial & { - disable_session_recording?: boolean; -}; - -export function PostHogProvider({ - children, - bootstrapData, -}: PropsWithChildren<{ bootstrapData?: BootstrapData }>) { - const key = buildEnv.NEXT_PUBLIC_POSTHOG_KEY; - const host = buildEnv.NEXT_PUBLIC_POSTHOG_HOST; - const initialBootstrap = useRef(undefined); - - if (!initialBootstrap.current && bootstrapData?.distinctID) { - initialBootstrap.current = bootstrapData; - } - - const options = useMemo(() => { - if (!host) return undefined; - const base: CapPostHogConfig = { - api_host: host, - capture_pageview: false, - capture_pageleave: true, - bootstrap: initialBootstrap.current?.distinctID - ? initialBootstrap.current - : undefined, - }; - - if (process.env.NEXT_PUBLIC_POSTHOG_DISABLE_SESSION_RECORDING === "true") { - base.disable_session_recording = true; - } - - return base; - }, [host]); - - if (!key || !host || !options) { - if (process.env.NODE_ENV !== "production") { - console.warn( - "Missing PostHog environment variables. Events will not be tracked.", - ); - } - return <>{children}; - } - - return ( - - - - {children} - - ); -} - -function PostHogBootstrapSync({ - bootstrapData, -}: { - bootstrapData?: BootstrapData; -}) { - const posthog = usePostHog(); - const previousFlags = useRef | undefined>( - undefined, - ); - - useEffect(() => { - if (!posthog || !bootstrapData) { - return; - } - - const nextFlags = bootstrapData.featureFlags ?? {}; - - if (areFlagMapsEqual(previousFlags.current, nextFlags)) { - return; - } - - if (typeof posthog.featureFlags?.override === "function") { - posthog.featureFlags.override(nextFlags); - previousFlags.current = nextFlags; - } - }, [posthog, bootstrapData]); - - return null; -} - -function areFlagMapsEqual( - left?: Record, - right?: Record, -) { - if (left === right) { - return true; - } - if (!left || !right) { - return !left && !right; - } - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) { - return false; - } - return leftKeys.every((key) => left[key] === right[key]); -} +import { type PropsWithChildren, useEffect, useState } from "react"; export function ReactQueryProvider({ children, diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 1971c197572..c6284c0a613 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -8,7 +8,7 @@ import { organizations, users, } from "@cap/database/schema"; -import { buildEnv, serverEnv } from "@cap/env"; +import { serverEnv } from "@cap/env"; import { STRIPE_AVAILABLE, stripe, userIsPro } from "@cap/utils"; import { OrganizationBrandingPatchBody } from "@cap/web-api-contract"; import { ImageUploads } from "@cap/web-backend"; @@ -17,9 +17,9 @@ import { zValidator } from "@hono/zod-validator"; import { and, eq, isNull } from "drizzle-orm"; import { Effect, Option } from "effect"; import { type Context, Hono } from "hono"; -import { PostHog } from "posthog-node"; import type Stripe from "stripe"; import { z } from "zod"; +import { scheduleServerProductEvent } from "@/lib/analytics/server"; import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout"; import { runPromise } from "@/lib/server"; import { withAuth, withOptionalAuth } from "../../utils"; @@ -782,31 +782,26 @@ app.post( success_url: redirects.successUrl, cancel_url: redirects.cancelUrl, allow_promotion_codes: true, - metadata: { platform: checkoutPlatform, dubCustomerId: user.id }, + metadata: { + platform: checkoutPlatform, + dubCustomerId: user.id, + analyticsIsFirstPurchase: user.stripeSubscriptionId ? "false" : "true", + }, }); if (checkoutSession.url) { console.log("[POST] Checkout session created successfully"); - - try { - const ph = new PostHog(buildEnv.NEXT_PUBLIC_POSTHOG_KEY || "", { - host: buildEnv.NEXT_PUBLIC_POSTHOG_HOST || "", - }); - - ph.capture({ - distinctId: user.id, - event: "checkout_started", - properties: { - price_id: priceId, - quantity: 1, - platform: checkoutPlatform, - }, - }); - - await ph.shutdown(); - } catch (e) { - console.error("Failed to capture checkout_started in PostHog", e); - } + scheduleServerProductEvent({ + eventId: `checkout:${checkoutSession.id}`, + eventName: "checkout_started", + platform: checkoutPlatform, + userId: user.id, + organizationId: user.activeOrganizationId, + properties: { + price_id: priceId, + quantity: 1, + }, + }); return c.json({ url: checkoutSession.url }); } diff --git a/apps/web/app/api/events/route.ts b/apps/web/app/api/events/route.ts new file mode 100644 index 00000000000..7e73d92e60c --- /dev/null +++ b/apps/web/app/api/events/route.ts @@ -0,0 +1,181 @@ +import { + createProductEventRows, + PRODUCT_ANALYTICS_LIMITS, +} from "@cap/analytics"; +import { serverEnv } from "@cap/env"; +import { + ProductAnalytics, + resolveProductAnalyticsActor, +} from "@cap/web-backend"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, + HttpApiSchema, + HttpServerRequest, +} from "@effect/platform"; +import { Effect, Layer, Schema } from "effect"; +import { + readProductAnalyticsBrowserToken, + readProductAnalyticsBrowserTokenClaims, +} from "@/lib/analytics/browser-token"; +import { + getProductAnalyticsRateLimitKey, + hasExpectedBrowserAnalyticsMetadata, + isAllowedAnonymousBrowserProductEvent, + isAuthenticatedAnalyticsRequestCandidate, + normalizeGeoHeader, + normalizeProductEventBatch, + ProductAnalyticsRateLimiter, +} from "@/lib/analytics/request"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; +import { apiToHandler } from "@/lib/server"; +import { allowedOrigins } from "@/utils/cors"; + +class RateLimited extends Schema.TaggedError()( + "RateLimited", + {}, + HttpApiSchema.annotations({ status: 429 }), +) {} + +class Api extends HttpApi.make("ProductAnalyticsApi").add( + HttpApiGroup.make("events").add( + HttpApiEndpoint.post("capture", "/api/events") + .setPayload( + Schema.Struct({ + events: Schema.Array(Schema.Unknown).pipe( + Schema.minItems(1), + Schema.maxItems(PRODUCT_ANALYTICS_LIMITS.batchSize), + ), + }), + ) + .addSuccess(Schema.Struct({ accepted: Schema.Number })) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.ServiceUnavailable) + .addError(RateLimited), + ), +) {} + +const RequestHeaders = Schema.Struct({ + authorization: Schema.optional(Schema.String), + "content-length": Schema.optional(Schema.String), + cookie: Schema.optional(Schema.String), + "sec-fetch-site": Schema.optional(Schema.String), + origin: Schema.optional(Schema.String), + "x-vercel-ip-country": Schema.optional(Schema.String), + "x-vercel-ip-country-region": Schema.optional(Schema.String), + "x-vercel-ip-city": Schema.optional(Schema.String), + "x-vercel-forwarded-for": Schema.optional(Schema.String), +}); + +const fallbackRateLimiter = new ProductAnalyticsRateLimiter(); + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "events", (handlers) => + Effect.gen(function* () { + const analytics = yield* ProductAnalytics; + + return handlers.handle("capture", ({ payload }) => + Effect.gen(function* () { + const headers = yield* HttpServerRequest.schemaHeaders( + RequestHeaders, + ).pipe(Effect.mapError(() => new HttpApiError.BadRequest())); + const requestMetadata = { + authorization: headers.authorization, + contentLength: headers["content-length"], + origin: headers.origin, + secFetchSite: headers["sec-fetch-site"], + }; + const browserClaims = + hasExpectedBrowserAnalyticsMetadata( + requestMetadata, + allowedOrigins, + ) && + readProductAnalyticsBrowserTokenClaims( + readProductAnalyticsBrowserToken(headers.cookie), + serverEnv().NEXTAUTH_SECRET, + ); + if ( + !browserClaims && + !isAuthenticatedAnalyticsRequestCandidate(requestMetadata) + ) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + if ( + fallbackRateLimiter.isRateLimited( + getProductAnalyticsRateLimitKey({ + trustedVercelProxy: process.env.VERCEL === "1", + xVercelForwardedFor: headers["x-vercel-forwarded-for"], + }), + ) + ) { + return yield* Effect.fail(new RateLimited()); + } + + if ( + yield* Effect.promise(() => + isRateLimited(RATE_LIMIT_IDS.PRODUCT_ANALYTICS_EVENTS), + ) + ) { + return yield* Effect.fail(new RateLimited()); + } + + const events = normalizeProductEventBatch(payload.events); + if (!events) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + const actor = yield* resolveProductAnalyticsActor; + if ( + !actor && + (!browserClaims || + !events.every((event) => + isAllowedAnonymousBrowserProductEvent( + event, + browserClaims.anonymousId, + ), + )) + ) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + const rows = createProductEventRows(events, { + receivedAt: new Date().toISOString(), + source: "client", + userId: actor?.userId, + organizationId: actor?.organizationId, + country: normalizeGeoHeader(headers["x-vercel-ip-country"]), + region: normalizeGeoHeader(headers["x-vercel-ip-country-region"]), + city: normalizeGeoHeader(headers["x-vercel-ip-city"], true), + }); + + yield* analytics + .append(rows) + .pipe( + Effect.catchTag("ProductAnalyticsError", (error) => + Effect.logWarning( + "Product analytics ingestion failed", + error, + ).pipe( + Effect.andThen( + Effect.fail(new HttpApiError.ServiceUnavailable()), + ), + ), + ), + ); + + return { accepted: rows.length }; + }), + ); + }), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const POST = handler; +export const OPTIONS = handler; diff --git a/apps/web/app/api/invite/accept/route.ts b/apps/web/app/api/invite/accept/route.ts index 6ca5d28d0d1..4f4d5e0e775 100644 --- a/apps/web/app/api/invite/accept/route.ts +++ b/apps/web/app/api/invite/accept/route.ts @@ -9,6 +9,10 @@ import { } from "@cap/database/schema"; import { and, eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; +import { + readAnalyticsAnonymousId, + scheduleServerProductEvent, +} from "@/lib/analytics/server"; import { normalizeAssignableOrganizationRole } from "@/lib/permissions/roles"; import { calculateProSeats, @@ -36,6 +40,10 @@ export async function POST(request: NextRequest) { } try { + let joinedMemberId: string | undefined; + let joinedOrganizationId: string | undefined; + let joinedRole: string | undefined; + let assignedProSeat = false; await db().transaction(async (tx) => { const [invite] = await tx .select() @@ -75,6 +83,9 @@ export async function POST(request: NextRequest) { role, }); memberId = newId; + joinedMemberId = newId; + joinedOrganizationId = invite.organizationId; + joinedRole = role; } const [org] = await tx @@ -116,6 +127,7 @@ export async function POST(request: NextRequest) { }); if (proSeatsRemaining > 0) { + assignedProSeat = true; await tx .update(organizationMembers) .set({ hasProSeat: true }) @@ -153,6 +165,21 @@ export async function POST(request: NextRequest) { .where(eq(organizationInvites.id, inviteId)); }); + if (joinedMemberId && joinedOrganizationId) { + scheduleServerProductEvent({ + eventId: `organization_member:${joinedMemberId}:joined`, + eventName: "organization_member_joined", + anonymousId: readAnalyticsAnonymousId(request), + platform: "web", + userId: user.id, + organizationId: joinedOrganizationId, + properties: { + role: joinedRole, + assigned_pro_seat: assignedProSeat, + }, + }); + } + return NextResponse.json({ success: true }); } catch (error) { if (error instanceof Error) { diff --git a/apps/web/app/api/settings/billing/guest-checkout/route.ts b/apps/web/app/api/settings/billing/guest-checkout/route.ts index 71889ee4e1e..fbaefa5c112 100644 --- a/apps/web/app/api/settings/billing/guest-checkout/route.ts +++ b/apps/web/app/api/settings/billing/guest-checkout/route.ts @@ -1,13 +1,19 @@ -import { buildEnv, serverEnv } from "@cap/env"; +import { randomUUID } from "node:crypto"; +import { serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import type { NextRequest } from "next/server"; -import { PostHog } from "posthog-node"; +import { + readAnalyticsAnonymousId, + scheduleServerProductEvent, +} from "@/lib/analytics/server"; import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout"; export async function POST(request: NextRequest) { console.log("Starting guest checkout process"); const { priceId, quantity, platform } = await request.json(); const checkoutPlatform = platform === "mobile" ? "mobile" : "web"; + const analyticsAnonymousId = readAnalyticsAnonymousId(request); + const checkoutAnonymousId = analyticsAnonymousId ?? `guest:${randomUUID()}`; console.log("Received guest checkout request:", { priceId, quantity }); @@ -31,32 +37,23 @@ export async function POST(request: NextRequest) { metadata: { platform: checkoutPlatform, guestCheckout: "true", + analyticsIsFirstPurchase: "true", + analyticsAnonymousId: checkoutAnonymousId, }, }); if (checkoutSession.url) { console.log("Successfully created guest checkout session"); - - try { - const ph = new PostHog(buildEnv.NEXT_PUBLIC_POSTHOG_KEY || "", { - host: buildEnv.NEXT_PUBLIC_POSTHOG_HOST || "", - }); - - ph.capture({ - distinctId: `guest-${checkoutSession.id}`, - event: "guest_checkout_started", - properties: { - price_id: priceId, - quantity: quantity || 1, - platform: checkoutPlatform, - session_id: checkoutSession.id, - }, - }); - - await ph.shutdown(); - } catch (e) { - console.error("Failed to capture guest_checkout_started in PostHog", e); - } + scheduleServerProductEvent({ + eventId: `checkout:${checkoutSession.id}`, + eventName: "guest_checkout_started", + anonymousId: checkoutAnonymousId, + platform: checkoutPlatform, + properties: { + price_id: priceId, + quantity: quantity || 1, + }, + }); return Response.json({ url: checkoutSession.url }, { status: 200 }); } diff --git a/apps/web/app/api/settings/billing/subscribe/route.ts b/apps/web/app/api/settings/billing/subscribe/route.ts index ebb9b816bad..b9464dae4c0 100644 --- a/apps/web/app/api/settings/billing/subscribe/route.ts +++ b/apps/web/app/api/settings/billing/subscribe/route.ts @@ -1,17 +1,21 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { users } from "@cap/database/schema"; -import { buildEnv, serverEnv } from "@cap/env"; +import { serverEnv } from "@cap/env"; import { stripe, userIsPro } from "@cap/utils"; import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; -import { PostHog } from "posthog-node"; import type Stripe from "stripe"; +import { + readAnalyticsAnonymousId, + scheduleServerProductEvent, +} from "@/lib/analytics/server"; export async function POST(request: NextRequest) { const user = await getCurrentUser(); let customerId = user?.stripeCustomerId; const { priceId, quantity, isOnBoarding } = await request.json(); + const analyticsAnonymousId = readAnalyticsAnonymousId(request); if (!priceId) { console.error("Price ID not found"); @@ -78,29 +82,25 @@ export async function POST(request: NextRequest) { platform: "web", dubCustomerId: user.id, isOnBoarding: isOnBoarding ? "true" : "false", + analyticsIsFirstPurchase: user.stripeSubscriptionId ? "false" : "true", + ...(analyticsAnonymousId ? { analyticsAnonymousId } : {}), }, }); if (checkoutSession.url) { - try { - const ph = new PostHog(buildEnv.NEXT_PUBLIC_POSTHOG_KEY || "", { - host: buildEnv.NEXT_PUBLIC_POSTHOG_HOST || "", - }); - - ph.capture({ - distinctId: user.id, - event: "checkout_started", - properties: { - price_id: priceId, - quantity: quantity, - platform: "web", - }, - }); - - await ph.shutdown(); - } catch (e) { - console.error("Failed to capture checkout_started in PostHog", e); - } + scheduleServerProductEvent({ + eventId: `checkout:${checkoutSession.id}`, + eventName: "checkout_started", + anonymousId: analyticsAnonymousId, + platform: "web", + userId: user.id, + organizationId: user.activeOrganizationId, + properties: { + price_id: priceId, + quantity: quantity ?? 1, + is_onboarding: Boolean(isOnBoarding), + }, + }); return Response.json({ url: checkoutSession.url }, { status: 200 }); } diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 7bb0ed9322b..2f6cce68a4d 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -1,13 +1,13 @@ import { db } from "@cap/database"; import { nanoId } from "@cap/database/helpers"; import { developerCreditTransactions, users } from "@cap/database/schema"; -import { buildEnv, serverEnv } from "@cap/env"; +import { serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import { Organisation, User } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { NextResponse } from "next/server"; -import { PostHog } from "posthog-node"; import type Stripe from "stripe"; +import { scheduleServerProductEvent } from "@/lib/analytics/server"; import { addCreditsToAccount } from "@/lib/developer-credits"; const relevantEvents = new Set([ @@ -17,6 +17,82 @@ const relevantEvents = new Set([ "customer.subscription.deleted", ]); +type PurchaseAnalyticsUser = Pick< + typeof users.$inferSelect, + "id" | "activeOrganizationId" +>; + +function isSettledSubscriptionPurchase( + session: Stripe.Checkout.Session, + subscription: Stripe.Subscription, +) { + return ( + (session.payment_status === "paid" || + session.payment_status === "no_payment_required") && + (subscription.status === "active" || subscription.status === "trialing") + ); +} + +function scheduleSubscriptionPurchaseEvents({ + eventId, + occurredAt, + session, + subscription, + inviteQuota, + user, + isFirstPurchase, +}: { + eventId: string; + occurredAt: string; + session: Stripe.Checkout.Session; + subscription: Stripe.Subscription; + inviteQuota: number; + user?: PurchaseAnalyticsUser; + isFirstPurchase: boolean; +}) { + const isGuestCheckout = session.metadata?.guestCheckout === "true"; + const platform = + session.metadata?.platform === "desktop" + ? "desktop" + : session.metadata?.platform === "mobile" + ? "mobile" + : session.metadata?.platform === "web" + ? "web" + : "server"; + const anonymousId = session.metadata?.analyticsAnonymousId; + const price = subscription.items.data[0]?.price; + const revenueProperties = { + payment_status: session.payment_status, + subscription_status: subscription.status, + amount_total_minor: session.amount_total, + amount_subtotal_minor: session.amount_subtotal, + discount_amount_minor: session.total_details?.amount_discount, + currency: session.currency, + unit_amount_minor: price?.unit_amount, + billing_interval: price?.recurring?.interval, + billing_interval_count: price?.recurring?.interval_count, + }; + + scheduleServerProductEvent({ + eventId: `stripe:${eventId}:purchase_completed`, + eventName: "purchase_completed", + occurredAt, + anonymousId, + platform, + userId: user?.id, + organizationId: user?.activeOrganizationId, + properties: { + ...revenueProperties, + invite_quota: inviteQuota, + price_id: price?.id, + quantity: inviteQuota, + is_onboarding: session.metadata?.isOnBoarding === "true", + is_first_purchase: isFirstPurchase, + is_guest_checkout: isGuestCheckout, + }, + }); +} + async function grantDeveloperCredits( session: Stripe.Checkout.Session, ): Promise { @@ -329,39 +405,17 @@ export const POST = async (req: Request) => { console.log("Successfully updated user in database"); - try { - const serverPostHog = new PostHog( - buildEnv.NEXT_PUBLIC_POSTHOG_KEY || "", - { host: buildEnv.NEXT_PUBLIC_POSTHOG_HOST || "" }, - ); - - const isFirstPurchase = !dbUser.stripeSubscriptionId; - const isGuestCheckout = session.metadata?.guestCheckout === "true"; - serverPostHog.capture({ - distinctId: dbUser.id, - event: "purchase_completed", - properties: { - subscription_id: subscription.id, - subscription_status: subscription.status, - invite_quota: inviteQuota, - price_id: subscription.items.data[0]?.price.id, - quantity: inviteQuota, - is_onboarding: session.metadata?.isOnBoarding === "true", - platform: - session.metadata?.platform === "desktop" || - session.metadata?.platform === "mobile" || - session.metadata?.platform === "web" - ? session.metadata.platform - : "unknown", - is_first_purchase: isFirstPurchase, - is_guest_checkout: isGuestCheckout, - }, + if (isSettledSubscriptionPurchase(session, subscription)) { + scheduleSubscriptionPurchaseEvents({ + eventId: event.id, + occurredAt: new Date(event.created * 1000).toISOString(), + session, + subscription, + inviteQuota, + user: dbUser, + isFirstPurchase: + session.metadata?.analyticsIsFirstPurchase === "true", }); - - await serverPostHog.shutdown(); - console.log("Successfully tracked purchase event in PostHog"); - } catch (error) { - console.error("Error tracking purchase in PostHog:", error); } } @@ -374,6 +428,45 @@ export const POST = async (req: Request) => { if (session.metadata?.type === "developer_credits") { return await grantDeveloperCredits(session); } + + if (typeof session.subscription === "string") { + const subscription = await stripe().subscriptions.retrieve( + session.subscription, + ); + if (isSettledSubscriptionPurchase(session, subscription)) { + const inviteQuota = subscription.items.data.reduce( + (total, item) => total + (item.quantity || 1), + 0, + ); + let dbUser: typeof users.$inferSelect | null = null; + if (typeof session.customer === "string") { + const customer = await stripe().customers.retrieve( + session.customer, + ); + if (!customer.deleted) { + const userId = customer.metadata.userId + ? User.UserId.make(customer.metadata.userId) + : undefined; + dbUser = await findUserWithRetry( + customer.email ?? "", + userId, + 1, + ); + } + } + + scheduleSubscriptionPurchaseEvents({ + eventId: event.id, + occurredAt: new Date(event.created * 1000).toISOString(), + session, + subscription, + inviteQuota, + ...(dbUser ? { user: dbUser } : {}), + isFirstPurchase: + session.metadata?.analyticsIsFirstPurchase === "true", + }); + } + } } if (event.type === "customer.subscription.updated") { diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f2c9726ccc1..69d42d61278 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import localFont from "next/font/local"; import Script from "next/script"; import type { PropsWithChildren } from "react"; +import { ProductAnalyticsPageView } from "./Layout/ProductAnalyticsPageView"; const defaultFont = localFont({ src: [ @@ -99,6 +100,7 @@ export default function RootLayout({ children }: PropsWithChildren) {