diff --git a/.github/workflows/analytics.yml b/.github/workflows/analytics.yml new file mode 100644 index 00000000000..ea3d3b35682 --- /dev/null +++ b/.github/workflows/analytics.yml @@ -0,0 +1,740 @@ +name: Analytics staging + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - ".github/workflows/analytics.yml" + - "package.json" + - "pnpm-lock.yaml" + - "packages/local-docker/**" + - "packages/analytics/**" + - "packages/database/**" + - "packages/env/server.ts" + - "packages/web-backend/src/ProductAnalytics/**" + - "packages/web-backend/src/Tinybird/**" + - "packages/web-backend/src/Organisations/**" + - "packages/web-domain/**" + - "apps/cli/**" + - "apps/desktop/**" + - "apps/web/**" + - "apps/cli/src/analytics.rs" + - "apps/mobile/**" + - "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/admin/analytics/**" + - "apps/web/app/api/analytics/**" + - "apps/web/app/api/cron/drain-product-analytics-outbox/**" + - "apps/web/app/api/cron/reconcile-product-analytics/**" + - "apps/web/app/api/cron/recover-organization-invite-delivery/**" + - "apps/web/app/api/cron/recover-product-analytics-erasure/**" + - "apps/web/app/api/cron/refresh-product-analytics/**" + - "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/mobile/checkout/**" + - "apps/web/app/api/mobile/**" + - "apps/web/app/utils/analytics.ts" + - "apps/web/app/utils/product-analytics.ts" + - "apps/web/lib/analytics/**" + - "apps/web/lib/organization-invite-delivery.ts" + - "apps/web/proxy.ts" + - "apps/web/vercel.json" + - "apps/web/lib/rate-limit.ts" + - "apps/web/workflows/*product-analytics*" + - "apps/web/workflows/recover-organization-invite-delivery.ts" + - "apps/desktop/src-tauri/src/lib.rs" + - "apps/desktop/src-tauri/src/product_analytics.rs" + - "apps/desktop/src/utils/analytics.ts" + - "apps/desktop/src/utils/product-analytics.ts" + - "apps/desktop/src/utils/*analytics*.test.ts" + - "scripts/analytics/**" + workflow_dispatch: + +permissions: + actions: read + contents: read + deployments: read + pull-requests: read + statuses: read + +concurrency: + group: >- + ${{ ((github.event_name == 'pull_request' && github.event.pull_request.number == 2003 && github.event.pull_request.head.ref == 'codex/first-party-analytics') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/codex/first-party-analytics')) && 'analytics-staging-37b8fef9-817f-4c3c-b21f-218c36a6077d' || format('analytics-staging-out-of-scope-{0}', github.run_id) }} + cancel-in-progress: false + +env: + TINYBIRD_WORKSPACE_ID: 37b8fef9-817f-4c3c-b21f-218c36a6077d + +jobs: + validate: + name: Validate exact analytics SHA + if: >- + (github.event_name == 'pull_request' && + github.event.pull_request.number == 2003 && + github.event.pull_request.head.ref == 'codex/first-party-analytics') || + (github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/codex/first-party-analytics') + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=5s + --health-timeout=5s + --health-retries=20 + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + EVENT_NUMBER: ${{ github.event.pull_request.number || '' }} + HEAD_REF: ${{ github.event.pull_request.head.ref || '' }} + steps: + - name: Check out exact requested SHA + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 1 + - uses: ./.github/actions/setup-js + - name: Refuse an out-of-scope event, branch, PR, or checkout + run: node scripts/analytics/staging-ci.js verify-scope --actual-sha "$(git rev-parse HEAD)" + - name: Run analytics contract and regression suites + run: pnpm analytics:test + - name: Prove analytics leases, receipts, and shared-cookie erasure in MySQL + env: + CAP_PRODUCT_ANALYTICS_MYSQL_E2E: "1" + run: >- + pnpm --filter @cap/web exec vitest run + __tests__/e2e/product-analytics-mysql-e2e.test.ts + - name: Validate Docker Compose + run: node scripts/analytics/analytics-cli.js compose-check + - name: Build and test Tinybird datafiles with fixtures + run: node scripts/analytics/analytics-cli.js local + - name: Stop Tinybird Local + if: always() + run: node scripts/analytics/analytics-cli.js local-stop + + validate-desktop: + name: Validate desktop analytics outbox + if: >- + (github.event_name == 'pull_request' && + github.event.pull_request.number == 2003 && + github.event.pull_request.head.ref == 'codex/first-party-analytics') || + (github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/codex/first-party-analytics') + runs-on: macos-14 + timeout-minutes: 30 + steps: + - name: Check out exact requested SHA + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 1 + - name: Install Rust 1.88 + uses: dtolnay/rust-toolchain@1.88.0 + - uses: ./.github/actions/setup-rust-cache + with: + target: aarch64-apple-darwin + - name: Prepare pinned native dependencies + run: node scripts/setup.js + - name: Create compile-only Tauri sidecars + run: | + target="$(rustc -vV | sed -n 's|host: ||p')" + binaries="apps/desktop/src-tauri/binaries" + install -d "$binaries" + for binary in cap-muxer cap-exporter cap-cli; do + install -m 755 /dev/null "$binaries/$binary-$target" + done + - name: Compile the desktop analytics path + run: cargo check -p cap-desktop --locked + - name: Prove durable desktop analytics behavior + run: cargo test -p cap-desktop product_analytics::tests --lib --locked + + deploy-staging: + name: Deploy and prove analytics staging + needs: [validate, validate-desktop] + if: needs.validate.result == 'success' && needs.validate-desktop.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 180 + environment: staging + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + EVENT_NUMBER: ${{ github.event.pull_request.number || '' }} + HEAD_REF: ${{ github.event.pull_request.head.ref || '' }} + ANALYTICS_TEST_RUN_ID: run_${{ github.run_id }}_${{ github.run_attempt }}_${{ github.event.pull_request.head.sha || github.sha }} + ANALYTICS_PREVIEW_ACCESS_URL: https://cap-web-git-codex-first-party-analytics-mc-ilroy.vercel.app + steps: + - name: Check out exact requested SHA + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 1 + - uses: ./.github/actions/setup-js + - name: Refuse an out-of-scope event, branch, PR, or checkout + run: node scripts/analytics/staging-ci.js verify-scope --actual-sha "$(git rev-parse HEAD)" + - name: Refuse missing, non-staging, or wrong-workspace credentials + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }} + TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN }} + TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }} + TINYBIRD_STAGING_INGEST_TOKEN: ${{ secrets.TINYBIRD_STAGING_INGEST_TOKEN }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + TINYBIRD_STAGING_CLEANUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_CLEANUP_TOKEN }} + run: node scripts/analytics/staging-ci.js verify-credentials + - name: Wait for successful exact-SHA Vercel preview + id: vercel + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/analytics/staging-ci.js wait-vercel + - name: Refuse a preview bound outside Tinybird staging + env: + ANALYTICS_PREVIEW_URL: ${{ env.ANALYTICS_PREVIEW_ACCESS_URL }} + CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }} + TINYBIRD_STAGING_CLEANUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_CLEANUP_TOKEN }} + TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }} + TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN }} + TINYBIRD_STAGING_INGEST_TOKEN: ${{ secrets.TINYBIRD_STAGING_INGEST_TOKEN }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }} + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }} + run: node scripts/analytics/staging-ci.js attest-preview + - name: Retire only a superseded Tinybird staging predecessor + id: retired-deployment + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js discard-retired-deployment + --artifact "$RUNNER_TEMP/analytics-retired-deployment.json" + - name: Upload retired Tinybird deployment evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: analytics-retired-deployment-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/analytics-retired-deployment.json + if-no-files-found: error + retention-days: 30 + - name: Persist the pre-create Tinybird recovery boundary + env: + VERCEL_DEPLOYMENT_ID: ${{ steps.vercel.outputs.deployment_id }} + VERCEL_PREVIEW_ACCESS_URL: ${{ env.ANALYTICS_PREVIEW_ACCESS_URL }} + VERCEL_PREVIEW_URL: ${{ steps.vercel.outputs.url }} + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js prepare-deployment-boundary + --output "$RUNNER_TEMP/analytics-deployment-boundary.json" + - name: Upload the immutable pre-create recovery boundary + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-00-precreate + path: ${{ runner.temp }}/analytics-deployment-boundary.json + if-no-files-found: error + retention-days: 30 + - name: Record Tinybird deployment boundary + run: echo "TINYBIRD_DEPLOYMENT_STARTED_AT=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" >> "$GITHUB_ENV" + - name: Check Tinybird cloud deployment plan + id: deployment-check + env: + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TB_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TB_HOST: ${{ secrets.TINYBIRD_STAGING_URL }} + run: >- + docker compose --file packages/local-docker/docker-compose.yml --profile analytics + run --rm tinybird-cloud-cli --cloud deployment create --allow-destructive-operations --check + - name: Create isolated Tinybird staging deployment + id: create-deployment + env: + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TB_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TB_HOST: ${{ secrets.TINYBIRD_STAGING_URL }} + run: | + set -o pipefail + set +e + docker compose --file packages/local-docker/docker-compose.yml --profile analytics run --rm tinybird-cloud-cli --cloud deployment create --allow-destructive-operations --wait | tee "$RUNNER_TEMP/tinybird-create-output.txt" + create_exit="${PIPESTATUS[0]}" + set -e + node -e 'const fs = require("node:fs"); fs.writeFileSync(process.argv[2], JSON.stringify({ exitCode: Number(process.argv[3]), output: fs.readFileSync(process.argv[1], "utf8") }));' "$RUNNER_TEMP/tinybird-create-output.txt" "$RUNNER_TEMP/tinybird-create-output.json" "$create_exit" + - name: Resolve only the deployment created by this run + id: tinybird + env: + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TB_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TB_HOST: ${{ secrets.TINYBIRD_STAGING_URL }} + run: | + docker compose --file packages/local-docker/docker-compose.yml --profile analytics run --rm tinybird-cloud-cli --cloud --output json deployment ls > "$RUNNER_TEMP/tinybird-deployments.json" + node scripts/analytics/staging-ci.js select-deployment --input "$RUNNER_TEMP/tinybird-deployments.json" --create-output "$RUNNER_TEMP/tinybird-create-output.json" --boundary "$RUNNER_TEMP/analytics-deployment-boundary.json" --minimum-created-at "$TINYBIRD_DEPLOYMENT_STARTED_AT" + - name: Prepare immutable synthetic cleanup state before ingestion + env: + VERCEL_DEPLOYMENT_ID: ${{ steps.vercel.outputs.deployment_id }} + VERCEL_PREVIEW_ACCESS_URL: ${{ env.ANALYTICS_PREVIEW_ACCESS_URL }} + VERCEL_PREVIEW_URL: ${{ steps.vercel.outputs.url }} + run: >- + node scripts/analytics/staging-ci.js prepare-seed + --run-id "$ANALYTICS_TEST_RUN_ID" + --deployment-id "${{ steps.tinybird.outputs.id }}" + --needs-promotion "${{ steps.tinybird.outputs.needs_promotion }}" + --boundary "$RUNNER_TEMP/analytics-deployment-boundary.json" + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Upload the immutable pre-ingestion recovery checkpoint + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-20-preseed + path: | + ${{ runner.temp }}/analytics-staging-state.json + ${{ runner.temp }}/analytics-staging-report.json + if-no-files-found: error + retention-days: 30 + - name: Refuse source mutations after exact-SHA checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain --untracked-files=no)" + - name: Re-run fixture assertions after staging creation + env: + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TB_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TB_HOST: ${{ secrets.TINYBIRD_STAGING_URL }} + run: node scripts/analytics/analytics-cli.js test + - name: Prove exact candidate endpoints before promotion + id: verify + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js verify-preseed + --deployment-id "${{ steps.tinybird.outputs.id }}" + --target "${{ steps.tinybird.outputs.needs_promotion == 'true' && 'staging' || 'live' }}" + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Persist the exact Tinybird promotion plan + id: promotion-plan + if: steps.tinybird.outputs.needs_promotion == 'true' + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js prepare-promotion + --deployment-id "${{ steps.tinybird.outputs.id }}" + --state "$RUNNER_TEMP/analytics-staging-state.json" + - name: Upload the immutable pre-promotion recovery checkpoint + if: steps.promotion-plan.outcome == 'success' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-30-prepromote + path: | + ${{ runner.temp }}/analytics-staging-state.json + ${{ runner.temp }}/analytics-staging-report.json + if-no-files-found: error + retention-days: 30 + - name: Promote the verified staging deployment + id: promote + if: steps.tinybird.outputs.needs_promotion == 'true' + continue-on-error: true + env: + GITHUB_TOKEN: ${{ github.token }} + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + git diff --exit-code + test -z "$(git status --porcelain --untracked-files=no)" + node scripts/analytics/staging-ci.js verify-pr-head + node scripts/analytics/staging-ci.js promote-deployment --deployment-id "${{ steps.tinybird.outputs.id }}" --state "$RUNNER_TEMP/analytics-staging-state.json" + - name: Resolve authoritative Tinybird state after the promotion attempt + id: deployment-state + if: always() && steps.tinybird.outcome == 'success' + env: + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TB_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TB_HOST: ${{ secrets.TINYBIRD_STAGING_URL }} + run: | + for resolution_attempt in $(seq 1 30); do + if ! docker compose --file packages/local-docker/docker-compose.yml --profile analytics run --rm tinybird-cloud-cli --cloud --output json deployment ls > "$RUNNER_TEMP/tinybird-final-deployments.json"; then + if [[ "$resolution_attempt" -eq 30 ]]; then + exit 1 + fi + sleep 5 + continue + fi + recover_pending=false + if [[ "$resolution_attempt" -eq 30 ]]; then + recover_pending=true + fi + if node scripts/analytics/staging-ci.js resolve-deployment-state --input "$RUNNER_TEMP/tinybird-final-deployments.json" --deployment-id "${{ steps.tinybird.outputs.id }}" --recover-pending "$recover_pending"; then + exit 0 + else + resolution_exit=$? + fi + if [[ "$resolution_exit" -ne 75 ]]; then + exit "$resolution_exit" + fi + sleep 5 + done + exit 1 + - name: Refuse to proceed without an authoritative live deployment + if: steps.deployment-state.outputs.promoted != 'true' || steps.promote.outcome == 'failure' + run: exit 1 + - name: Seed bounded duplicate and conflict probes into exact live staging + id: seed + env: + VERCEL_DEPLOYMENT_ID: ${{ steps.vercel.outputs.deployment_id }} + VERCEL_PREVIEW_URL: ${{ steps.vercel.outputs.url }} + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_STAGING_INGEST_TOKEN: ${{ secrets.TINYBIRD_STAGING_INGEST_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js seed + --run-id "$ANALYTICS_TEST_RUN_ID" + --deployment-id "${{ steps.tinybird.outputs.id }}" + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Upload the immutable post-ingestion recovery checkpoint + if: steps.seed.outcome == 'success' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-35-postseed + path: | + ${{ runner.temp }}/analytics-staging-state.json + ${{ runner.temp }}/analytics-staging-report.json + if-no-files-found: error + retention-days: 30 + - name: Prove least-privilege staging token scopes + id: token-scopes + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }} + TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_ERASURE_LOOKUP_TOKEN }} + TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + TINYBIRD_STAGING_INGEST_TOKEN: ${{ secrets.TINYBIRD_STAGING_INGEST_TOKEN }} + TINYBIRD_STAGING_CLEANUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_CLEANUP_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js verify-token-scopes + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Prove the exact-SHA deployed browser tracker + id: browser-e2e + env: + ANALYTICS_PREVIEW_URL: ${{ env.ANALYTICS_PREVIEW_ACCESS_URL }} + ANALYTICS_BROWSER_RUN_ID: ${{ env.ANALYTICS_TEST_RUN_ID }}_preview + ANALYTICS_STATE_PATH: ${{ runner.temp }}/analytics-staging-state.json + ANALYTICS_ARTIFACT_PATH: ${{ runner.temp }}/analytics-staging-report.json + CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }} + run: | + pnpm --dir apps/chrome-extension exec playwright install --with-deps chromium + pnpm --dir apps/chrome-extension exec playwright test e2e/analytics-staging.spec.ts + - name: Probe the exact-SHA Vercel browser collector and staging rate limit + id: preview-api + env: + CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }} + run: >- + node scripts/analytics/staging-ci.js probe-preview + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Prove exact-SHA durable server delivery + id: server-delivery + env: + CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }} + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }} + run: >- + node scripts/analytics/staging-ci.js probe-server + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Rebuild promoted decision and health copies + id: promoted-copies + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js run-copies + --deployment-id "${{ steps.tinybird.outputs.id }}" + --phase promoted + --target live + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Measure populated decision endpoint performance + id: populated-performance + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js verify + --deployment-id "${{ steps.tinybird.outputs.id }}" + --baseline-deployment-id "${{ steps.promote.outputs.previous_live_id || steps.tinybird.outputs.id }}" + --target live + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Prove promoted delivery, business values, and decision deduplication + id: verify-promoted + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js verify-promoted + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Prove exact staging rollback and restoration + id: rollback-drill + if: steps.verify-promoted.outcome == 'success' && steps.promote.outputs.promoted == 'true' + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js drill-rollback + --deployment-id "${{ steps.tinybird.outputs.id }}" + --previous-live-id "${{ steps.promote.outputs.previous_live_id }}" + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Delete the scoped identity through the exact-SHA application path + id: erase-identity + env: + CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }} + run: >- + node scripts/analytics/staging-ci.js erase-synthetic-identity + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Prove identity erasure and out-of-scope control preservation + id: verify-erasure + if: steps.erase-identity.outcome == 'success' + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js verify-synthetic-identity-erasure + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Quiesce scheduled and active Copy jobs before final cleanup + id: pause-copies + if: always() && steps.seed.outcome != 'skipped' && steps.deployment-state.outputs.promoted == 'true' + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js set-copy-schedules + --action pause + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Delete strictly scoped synthetic raw rows + id: cleanup + if: always() && steps.seed.outcome != 'skipped' && (steps.deployment-state.outputs.target == 'staging' || steps.pause-copies.outcome == 'success') + env: + CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }} + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_CLEANUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_CLEANUP_TOKEN }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }} + run: | + if [[ ! -f "$RUNNER_TEMP/analytics-staging-state.json" ]]; then + echo "required=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "required=true" >> "$GITHUB_OUTPUT" + node scripts/analytics/staging-ci.js cleanup \ + --deployment-id "${{ steps.tinybird.outputs.id }}" \ + --target "${{ steps.deployment-state.outputs.target || (steps.tinybird.outputs.needs_promotion == 'true' && 'staging' || 'live') }}" \ + --state "$RUNNER_TEMP/analytics-staging-state.json" \ + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Retract synthetic rows from every derived copy + id: cleanup-copies + if: always() && steps.cleanup.outputs.required == 'true' && steps.cleanup.outcome == 'success' && steps.cleanup.outputs.requires_copies == 'true' + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js run-copies + --deployment-id "${{ steps.tinybird.outputs.id }}" + --phase cleanup + --target "${{ steps.cleanup.outputs.target }}" + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Prove synthetic cleanup no longer affects queries + id: verify-cleanup + if: always() && steps.cleanup-copies.outcome == 'success' + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js verify-cleanup + --deployment-id "${{ steps.tinybird.outputs.id }}" + --target "${{ steps.cleanup-copies.outputs.target }}" + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Resume reviewed Copy schedules + id: resume-copies + if: always() && steps.pause-copies.outcome == 'success' + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js set-copy-schedules + --action resume + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Persist finalization eligibility + id: recovery-ready + if: steps.pause-copies.outcome == 'success' && steps.verify-cleanup.outcome == 'success' && steps.rollback-drill.outcome == 'success' && steps.resume-copies.outcome == 'success' + run: >- + node scripts/analytics/staging-ci.js mark-recovery-ready + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Upload the immutable pre-finalization recovery checkpoint + if: steps.recovery-ready.outcome == 'success' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-40-ready-to-finalize + path: | + ${{ runner.temp }}/analytics-staging-state.json + ${{ runner.temp }}/analytics-staging-report.json + if-no-files-found: error + retention-days: 30 + - name: Finalize the fully verified staging promotion + id: finalize + if: steps.recovery-ready.outcome == 'success' + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js finalize-promotion + --deployment-id "${{ steps.tinybird.outputs.id }}" + --previous-live-id "${{ steps.promote.outputs.previous_live_id }}" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Restore the previous staging deployment on failure + id: rollback + if: always() && steps.promote.outputs.previous_live_id != '' && steps.finalize.outcome != 'success' && steps.rollback-drill.outputs.rollback_target_usable != 'false' && (steps.seed.outcome == 'skipped' || steps.verify-cleanup.outcome == 'success') + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + run: >- + node scripts/analytics/staging-ci.js rollback-promotion + --deployment-id "${{ steps.tinybird.outputs.id }}" + --previous-live-id "${{ steps.promote.outputs.previous_live_id }}" + --state "$RUNNER_TEMP/analytics-staging-state.json" + --artifact "$RUNNER_TEMP/analytics-staging-report.json" + - name: Discard an unpromoted staging deployment after cleanup + id: discard + if: always() && steps.rollback.outcome != 'success' && (steps.deployment-state.outputs.discard == 'true' || steps.cleanup.outputs.requires_discard == 'true') + env: + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + run: | + discard_args=(--deployment-id "${{ steps.tinybird.outputs.id }}") + if [[ -f "$RUNNER_TEMP/analytics-staging-report.json" ]]; then + discard_args+=(--artifact "$RUNNER_TEMP/analytics-staging-report.json") + fi + node scripts/analytics/staging-ci.js discard-deployment "${discard_args[@]}" + - name: Upload redacted staging evidence + if: always() && steps.cleanup.outputs.required == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: analytics-staging-${{ github.event.pull_request.head.sha || github.sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/analytics-staging-report.json + if-no-files-found: error + retention-days: 30 + + recover-staging: + name: Recover interrupted analytics staging + needs: deploy-staging + if: always() && needs.deploy-staging.result != 'success' + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: staging + permissions: + actions: read + contents: read + deployments: read + pull-requests: read + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + EVENT_NUMBER: ${{ github.event.pull_request.number || '' }} + HEAD_REF: ${{ github.event.pull_request.head.ref || '' }} + ANALYTICS_TEST_RUN_ID: run_${{ github.run_id }}_${{ github.run_attempt }}_${{ github.event.pull_request.head.sha || github.sha }} + steps: + - name: Check out the interrupted exact SHA + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 1 + - name: Locate immutable recovery checkpoints + id: checkpoints + env: + GH_TOKEN: ${{ github.token }} + CHECKPOINT_PREFIX: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}- + run: | + count="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" --jq '[.artifacts[] | select(.expired == false and (.name | startswith(env.CHECKPOINT_PREFIX)))] | length')" + if [[ "$count" -eq 0 ]]; then + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "found=true" >> "$GITHUB_OUTPUT" + - name: Download immutable recovery checkpoints + if: steps.checkpoints.outputs.found == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: analytics-recovery-${{ github.run_id }}-${{ github.run_attempt }}-* + path: ${{ runner.temp }}/analytics-recovery-checkpoints + - name: Reconcile and recover only the owned staging run + id: recover + if: steps.checkpoints.outputs.found == 'true' + env: + CAP_ANALYTICS_STAGING_TEST_SECRET: ${{ secrets.CAP_ANALYTICS_STAGING_TEST_SECRET }} + TINYBIRD_STAGING_URL: ${{ secrets.TINYBIRD_STAGING_URL }} + TINYBIRD_STAGING_CLEANUP_TOKEN: ${{ secrets.TINYBIRD_STAGING_CLEANUP_TOKEN }} + TINYBIRD_STAGING_COPY_TOKEN: ${{ secrets.TINYBIRD_STAGING_COPY_TOKEN }} + TINYBIRD_STAGING_DEPLOY_TOKEN: ${{ secrets.TINYBIRD_STAGING_DEPLOY_TOKEN }} + TINYBIRD_STAGING_READ_TOKEN: ${{ secrets.TINYBIRD_STAGING_READ_TOKEN }} + TINYBIRD_STAGING_SCHEDULER_TOKEN: ${{ secrets.TINYBIRD_STAGING_SCHEDULER_TOKEN }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + VERCEL_PREVIEW_SHARE_SECRET: ${{ secrets.VERCEL_PREVIEW_SHARE_SECRET }} + run: >- + node scripts/analytics/staging-ci.js recover + --checkpoint-directory "$RUNNER_TEMP/analytics-recovery-checkpoints" + --artifact "$RUNNER_TEMP/analytics-recovery-report.json" + - name: Upload redacted recovery evidence + if: always() && steps.checkpoints.outputs.found == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: analytics-recovery-result-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/analytics-recovery-report.json + if-no-files-found: error + retention-days: 30 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/.gitignore b/.gitignore index 81b687fe8c4..c5255fd0052 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,8 @@ tauri.linux.conf.json # Cursor .cursor .env*.local +scripts/analytics/tinybird/fixtures/product_events_v1.local.ndjson +scripts/analytics/tinybird/fixtures/local-dates.json .docs/ .claude/ .claude/settings.local.json diff --git a/Cargo.lock b/Cargo.lock index e44be582793..ca0fe671aa0 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", ] @@ -1478,6 +1478,7 @@ dependencies = [ "image 0.25.8", "kameo", "keyed_priority_queue", + "keyring", "lazy_static", "libappindicator", "log", @@ -1494,11 +1495,11 @@ dependencies = [ "parakeet-rs", "png 0.17.16", "pollster", - "posthog-rs", "rand 0.8.5", "regex", "relative-path", - "reqwest 0.12.24", + "reqwest", + "ring", "rodio", "sanitize-filename", "scap-direct3d", @@ -1551,7 +1552,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 +3394,7 @@ dependencies = [ "rustc_version", "toml 0.9.7", "vswhom", - "winreg 0.55.0", + "winreg", ] [[package]] @@ -4464,25 +4465,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 +4632,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 +4651,7 @@ dependencies = [ "bytes", "futures-core", "http 1.3.1", - "http-body 1.0.1", + "http-body", "pin-project-lite", ] @@ -4702,30 +4673,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 +4683,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 +4696,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 +4703,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 +4721,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.7.0", + "hyper", "hyper-util", "native-tls", "tokio", @@ -4808,14 +4741,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 +7172,7 @@ dependencies = [ "bytes", "http 1.3.1", "opentelemetry", - "reqwest 0.12.24", + "reqwest", ] [[package]] @@ -7254,7 +7187,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest 0.12.24", + "reqwest", "thiserror 2.0.16", "tracing", ] @@ -7837,20 +7770,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 +8025,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 +8045,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 +8473,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 +8486,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 +8502,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 +8520,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.2", + "webpki-roots", ] [[package]] @@ -8893,18 +8771,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 +8780,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 +8804,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 +9079,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 +9198,7 @@ checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030" dependencies = [ "httpdate", "native-tls", - "reqwest 0.12.24", + "reqwest", "sentry-actix", "sentry-anyhow", "sentry-backtrace", @@ -10306,12 +10143,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 +10200,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 +10208,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 +10351,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.12.24", + "reqwest", "serde", "serde_json", "serde_repr", @@ -10762,7 +10572,7 @@ dependencies = [ "data-url", "http 1.3.1", "regex", - "reqwest 0.12.24", + "reqwest", "schemars 0.8.22", "serde", "serde_json", @@ -10958,7 +10768,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.12.24", + "reqwest", "semver", "serde", "serde_json", @@ -11420,23 +11230,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 +11408,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 +11439,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -11656,7 +11456,7 @@ dependencies = [ "bytes", "futures-util", "http 1.3.1", - "http-body 1.0.1", + "http-body", "iri-string", "pin-project-lite", "tower", @@ -12086,7 +11886,7 @@ dependencies = [ "log", "native-tls", "percent-encoding", - "rustls-pemfile 2.2.0", + "rustls-pemfile", "rustls-pki-types", "socks", "ureq-proto", @@ -12584,12 +12384,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 +13403,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 +13483,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/analysis/first-party-analytics-production-readiness.md b/analysis/first-party-analytics-production-readiness.md new file mode 100644 index 00000000000..8999ff9b743 --- /dev/null +++ b/analysis/first-party-analytics-production-readiness.md @@ -0,0 +1,115 @@ +# First-party analytics contract and rollout + +## Decision contract + +Cap analytics is at-least-once at the delivery boundary and exactly once for decision metrics. Every event carries a stable `event_id` and a SHA-256-derived payload fingerprint. Untrusted client IDs are rewritten into an anonymous-identity-bound hashed `client:` trust domain, while authoritative server IDs retain their established stable values, so a client cannot preclaim a server-owned business fact and in-flight server events keep their deployment-spanning deduplication identity. Client relationship IDs such as `page_view_id` are rewritten into the same client domain. Client retry fingerprints cover immutable normalized client input and exclude request-time user, organization, geography, device, network, traffic, and synthetic enrichment, so a retry after sign-in or a network change remains the same event. Retry deliveries retain the same event ID and fingerprint. The raw Tinybird datasource is append-only; health metrics preserve every accepted delivery. Canonical and decision snapshots group by `event_id`, count one event only when every accepted copy has the same fingerprint, and quarantine an ID when different fingerprints arrive. A conflicting member of a client batch is rejected independently while valid neighbors continue to ingestion. + +The central registry is `packages/analytics/src/event-registry.ts`. It defines the version, authority, delivery class, permitted platforms, semantic, and property schema for every event. Contract CI rejects unknown literal names, templates, invalid property keys, missing emitters, and drift between the Rust desktop catalogue and the TypeScript registry. Invalid properties reject the whole event rather than being silently removed. + +Critical server facts are committed to a durable MySQL outbox before the capture call succeeds. Business paths that already own a database transaction write the business mutation and analytics fact atomically; other paths persist the analytics fact in their own transaction immediately after the authoritative mutation. A leased dispatcher starts Vercel Workflow outside the database transaction, retries transient workflow-start and provider failures with the original ID, reclaims unconfirmed runs, and exposes pending age, dead letters, and payload conflicts. Signup, share, and collaboration facts are also reconciled from authoritative database rows every day with deterministic event IDs, closing the remaining mutation-to-outbox gap. Stripe remains authoritative for purchases, trials, renewals, plan and seat changes, cancellation, churn, refunds, and payment failures; Stripe webhook retries and daily paginated reconciliation close its enqueue gap. + +Desktop critical events enter a bounded encrypted local outbox before network delivery. The encryption key is stored in the operating-system keyring, events survive restart and offline periods, retryable failures back off, permanent failures enter a bounded dead-letter queue, and counters expose accepted, retried, dropped, overflowed, and dead-lettered events. Mobile critical events enter a bounded atomic AsyncStorage outbox before capture returns; interrupted rotations recover the last valid backup, account-scoped queues are isolated, offline and restart delivery resumes with the original event ID, and terminal failures enter a bounded dead-letter ledger. Browser events are intentionally best effort: attempted, accepted, retried, dropped, overflowed, and oversize counts accompany later batches without recursively creating analytics events. + +## Identity, sessions, attribution, and time + +- Visitor: a first-party pseudonymous browser ID stored in a persistent cookie/local storage. It is not a claim that the actor is a person. +- Session: a browser visit shared across tabs and renewed after 30 minutes of inactivity. Reloads and SPA navigation retain the session; activity at 29 minutes extends it; a return after more than 30 minutes starts a new session. Hidden time is not engagement time. +- Actor: `user_id` when authenticated, otherwise the anonymous visitor ID, otherwise the session ID. A canonical `identity_linked` event maps pre-auth acquisition to signup, while a settled guest purchase may establish the same mapping from its preserved anonymous checkout identity. The privacy-safe funnel materialization returns cohort counts only and never exposes that mapping. +- User: an authenticated Cap account. Cross-device creator metrics use `user_id`. +- Organization: the authoritative organization attached to a server fact. Organization metrics never infer membership from an anonymous browser event. + +Metric dates and cohort boundaries use UTC. First touch is the first campaign stored for the visitor. Session touch is captured only when a new 30-minute session begins. Last touch is the most recent campaign-bearing navigation. A new campaign during an existing session updates last touch but does not relabel session touch. Advertising click IDs are retained only as bounded attribution strings. + +The activation metric is the first authoritative `share_link_created` event within seven UTC days of `user_signed_up`. It represents a creator who produced a shareable Cap, not a click, checkout, onboarding screen, or client-only intent. Onboarding milestones are diagnostic best-effort events and do not replace activation. + +## Traffic and privacy model + +Cap chose a persistent first-party pseudonymous visitor identifier, not Plausible's cookieless daily hash model. This supports returning-visitor, multi-tab session, anonymous-to-signup, and attribution analysis, but it requires transparent disclosure and the existing telemetry preference. The identifier contains no email, name, IP address, or device fingerprint. + +The collector rejects unregistered events, unknown properties, raw error fields, invalid identifiers, excessive cardinality, and oversized requests. Raw customer content, filenames, transcripts, email addresses, raw errors, raw IP addresses, and raw user-agent strings are not analytics properties. IP and user-agent data are used only in the request process to derive internal/bot classification and coarse country, region, city, browser, device, and operating-system dimensions; raw values are not stored. + +Known bots, crawlers, previews, internal IP hashes, and synthetic runs are visible to health monitoring but excluded from decision snapshots. Short-lived signed browser claims are rate-limited by both source IP and anonymous ID; browsers never receive a Tinybird write token. Replay and automation can be made expensive and observable, but public traffic analytics cannot cryptographically guarantee a human browser. Signup, organization, billing, and other business outcomes therefore remain server-authoritative. + +Vercel request-IP buckets use only the platform-owned forwarding header and retain only its SHA-256 hash in process memory. A self-hosted deployment does not trust forwarding headers by default, so product-event collection and legacy public view tracking return `503` until `CAP_ANALYTICS_TRUST_PROXY=1` is configured. That setting uses the first `x-forwarded-for` address and is safe only behind a reverse proxy that overwrites, rather than appends or passes through, that header. Do not set it on Vercel. Client-controlled anonymous IDs and authorization strings never become network rate-limit keys. + +The desktop critical-event outbox uses AES-256-GCM. Its stable random key is stored in the OS keyring and in a separate app-local recovery-key file so queued events remain recoverable during temporary keyring outages; Unix recovery-key permissions are `0600`, while Windows relies on the per-user app-data ACL. This protects queue contents from accidental store disclosure but does not claim protection from a process or local account with access to both app-data files. An upgrade may read the earlier application-store fallback-key shape only to decrypt and re-encrypt pending queues, then removes that legacy value after both primary and recovery queues are safely consolidated. + +Account and organization deletion first fences the user and organization identities in MySQL, records linked anonymous identities in a durable identity ledger, and queues a recoverable erasure request. New delivery and reconciliation reject fenced identities. Erasure then acquires the global fencing lease, waits for bounded ingestion leases, removes matching raw rows with a dedicated erasure token, rebuilds every replace-mode canonical, traffic, activation, retention, attribution, experiment, and health snapshot, and deletes only anonymous aliases that are exclusive to the erased principals. Event receipts and queued rows for the erased scope are removed as part of the same recovery contract. This closes both append-after-delete and shared-browser over-deletion races. Deletion fails closed if the erasure credential or any rebuild is unavailable. The staging suite deletes a synthetic user and organization, proves their raw-health and decision state is gone, proves replay is suppressed, and proves an out-of-scope row sharing the anonymous ID remains until final test cleanup. Tinybird's row-deletion API currently requires the general `DATASOURCES:CREATE` scope and rejects resource-scoped creation of that operator; the token therefore has no read or deployment scope, lives only in the protected staging environment, and is used by code that accepts bounded validated deletion predicates. Its broader same-workspace mutation capability is an explicit provider limitation rather than a least-privilege claim. + +Raw, canonical, and decision data share an 800-day TTL. This supports two complete 365-day comparison windows plus a rebuild buffer for year-over-year decisions. The identity-bearing source is intentionally retained for the same horizon as its exact aggregates so a later account or organization deletion can still retract every historical contribution. Keeping only irreversible long-lived counts would use less storage but would break the derived-erasure guarantee. Health-detail aggregates remain limited to 90 days. This retention choice requires privacy-owner approval before production rollout and must be disclosed with the persistent first-party identifier model. + +## Data quality and performance gates + +The staging workflow is restricted to PR 2003 and `codex/first-party-analytics`, hard-codes the staging Tinybird workspace ID, checks the exact Git SHA, waits for the exact-SHA Vercel preview, and requires that preview to attest the reviewed staging database credential fingerprint and migration `0042_lying_sharon_ventura` before any synthetic mutation. It creates a Tinybird candidate inside that staging workspace, pins every candidate read to its exact numeric deployment ID, proves all typed endpoints execute within absolute latency budgets, and proves the unique run IDs are absent from the candidate, pinned live deployment, and default live route before promotion. Tinybird staging empirically exposed rows written with `__tb__min_deployment` to the live deployment even though its deployment documentation describes candidate-only visibility. The workflow therefore never writes synthetic rows or runs Copy mutations against a candidate: it promotes the exact candidate inside the dedicated staging workspace first, re-resolves its authoritative live state, and only then seeds and fully tests that exact deployment. Cleanup also deletes the bounded run IDs from the pinned live source before discarding any rejected candidate, so a provider isolation regression cannot leave synthetic rows behind. The prior staging deployment remains available until promoted Copy results, public business values, retained-deployment and representative-volume performance, erasure, cleanup, durable MySQL outbox health, and a live rollback-and-restoration drill pass. The first rollout of a new endpoint compares only shared endpoints to the retained deployment, records the new endpoint as having no historical baseline, and still applies exact-deployment and representative absolute budgets. A no-op deployment uses absolute budgets rather than presenting the same deployment as an independent regression baseline. The rollback drill probes candidate-only endpoints against the retained deployment and excludes only endpoints that return `404`; every available typed endpoint must remain readable. The exact-SHA admin client applies the same narrow rollback compatibility rule, then the drill restores the candidate and re-proves the full endpoint suite, health, and synthetic business responses. Ambiguous live-switch responses are reconciled against the exact deployment pair before recovery continues. Any earlier failure cleans the exact synthetic run, restores a data-plane-proven prior deployment, and removes only the rejected candidate. + +The workflow persists immutable checkpoints before deployment creation, before ingestion, before promotion, after successful ingestion, and before finalization. Its same-run recovery job resolves uncertain creation from the exact deployment-ID set difference. If the candidate was promoted, recovery pauses its reviewed Copy schedules, removes the bounded synthetic run, rebuilds derived state, resumes the schedules, restores and probes the prior read plane, and only then discards the owned candidate. GitHub force-cancellation or a GitHub control-plane outage can still prevent that same-workflow recovery job from starting; the recorded checkpoint is the manual recovery input in that case. The workflow has no push trigger, production environment, production token, or production deployment command. + +The redacted evidence artifact records the Git SHA, GitHub run, Vercel and Tinybird deployment IDs, pre-seed exact-deployment proof, hashed synthetic-run identity, delivery attempts, ingestion throughput, endpoint and full-dashboard p50/p95/p99, measured-baseline regressions, visibility time, health totals, decision-dedup assertions, conflict quarantine, least-privilege token checks, scoped identity erasure, and cleanup. Synthetic rows are excluded from normal metrics and cleanup is verified after every promoted run. + +Required live gates are: + +- duplicate deliveries remain visible in health while one non-conflicting event reaches the decision assertion; +- the same ID with different payloads is visible as a conflict and contributes zero decision events; +- unique load events remain complete and conflict-free; +- ingestion visibility is within the measured SLO; +- representative endpoint p95 is within the measured baseline budget; +- raw and all derived synthetic state is removed successfully; +- an erased identity disappears from raw and derived results while an out-of-scope control remains; +- the expiring aggregate-read JWT has exactly the reviewed endpoint scopes, cannot query raw identifiers, append, list jobs, or execute Copy Pipes, and append/cleanup tokens cannot read aggregate endpoints; +- a bounded five-event fixture produces exact non-zero traffic, page, activation, and retention values while remaining absent from normal decision endpoints; +- a populated-table performance pass measures every typed decision endpoint and full dashboard fanout after materialization; +- wrong workspace, stale SHA, missing credentials, partial execution, failed promotion, and failed cleanup all fail closed. + +Copy-backed decision tables rebuild through a durable single-owner Vercel Workflow every ten minutes to avoid competing for the same Tinybird worker pool. The routine refresh runs the eight decision models from the streaming exact event-ID state. Exact-SHA staging and erasure runs additionally rebuild global and day-partitioned event state, canonical state, and health, for twelve ordered copies in total. Both event-state copies must reach terminal success before canonical rebuilding begins. Exact live-staging ingestion visibility and promoted Copy visibility are measured separately, so neither proof waits for the periodic refresh. Dashboard freshness exposes product, traffic, retention, identity-funnel, attribution, and experiment-outcome aggregate timestamps; scheduled production freshness is therefore bounded by the documented workflow cadence plus provider execution time. + +The checked-in bounded v2 hot/cold models are deployment-validated side-by-side resources, not the current public read plane. Routine dashboard and agent endpoints continue to read the exact pre-aggregated decision tables and never scan raw events. A future cutover must backfill settled UTC dates, publish complete cross-model generations, prove parity against the current tables, add physical cold-state erasure rebuilding, and then switch endpoints. Until those gates exist, the v2 resources remain unscheduled and cannot affect decision metrics. + +The staging run records absolute ingestion budgets, compares shared typed endpoints and dashboard fanout against the retained prior deployment, and separately measures 1,000-row and 100,000-row mixed high-cardinality corpora spread across 30 and 80 UTC days with nonzero traffic, activation, retention, identity, attribution, experimentation, product, and revenue assertions. It separately measures direct provider ingestion and the exact-SHA `/api/events` collector with 20 full batches at controlled concurrency, zero-error and throughput gates. Browser capture uses 30 alternating matched-control samples on the deployed page; desktop capture measures 100 real encrypted and fsynced journal appends. Endpoint workloads use 30 interleaved samples and report p50, p95, p99, mean, standard deviation, and coefficient of variation. A newly introduced endpoint without an honest prior baseline is labeled as such and must pass measured and representative absolute budgets. A green static/unit run alone is not rollout evidence. + +## Production rollout checklist + +Production remains prohibited until the final staging artifact, relevant CI, security/data/performance reviews, and Greptile are green for the same branch SHA. It is additionally blocked until Cap provisions an analytics-only production Tinybird workspace, or Tinybird provides an equivalently protected resource-scoped row-deletion capability: the current `DATASOURCES:CREATE` requirement would otherwise let an erasure credential mutate unrelated datasources in a shared production workspace. + +The preview-only `/api/analytics/staging-test` route must not receive a production secret. Its `CAP_ANALYTICS_STAGING_TEST_SECRET` exists only in the Vercel Preview environment and the protected GitHub staging environment; the route returns `404` outside `VERCEL_ENV=preview` and requires the exact Vercel Git SHA. + +1. Generate and review the database migrations from `packages/database/schema.ts`; confirm `0040_chief_skreet.sql`, `0041_complex_outlaw_kid.sql`, and `0042_lying_sharon_ventura.sql` exactly match the reviewed schema. Create a PlanetScale deploy request targeting production `main`, require zero destructive operations and a green schema lint, and obtain separate production approval before deployment. After deployment, attest the analytics outbox, receipt, identity, ingestion-lease, erasure-request, reconciliation-failure, refresh-lease, invite-delivery, and reconciliation index shapes before deploying application code. +2. Create an analytics-only production Tinybird workspace, then create its resources from the reviewed datafiles with a deploy credential scoped only to that workspace. Run `deployment create --check`, review destructive/schema changes, create an isolated deployment, run fixture tests, rebuild every copy, query all aggregate endpoints, then promote. Record the workspace and deployment IDs. Do not reuse the staging workspace or tokens. +3. Create least-privilege Tinybird tokens: + - append-only token for `product_events_v1`; + - expiring JWT with exactly the reviewed 18 aggregate endpoint `PIPES:READ` scopes, no raw or canonical datasource access, and an expiry alert plus rotation procedure that replaces it before the six-hour runway; never create a static endpoint-read token because Tinybird's static `PIPES:READ` credential can start an on-demand Copy Pipe by name outside its declared resources; + - resource-scoped copy token for only the reviewed Copy Pipes and targets enumerated by the staging runner, with no endpoint, raw identity, or Jobs API access; submit each mutation once and use the separate schedule controller for job attestation; + - erasure-lookup token limited to read access on `product_events_v1` and `product_events_canonical_v1`, protected from all agent and admin surfaces; + - schedule-controller token with only Tinybird's `PIPES:CREATE` and `DATASOURCES:CREATE` scopes in the target workspace; Tinybird does not resource-bind these operational scopes, so application code must enforce the reviewed Copy Pipe allowlist, use it only for schedule control and Copy job-list attestation, and prove the token fails aggregate-read probes; + - dedicated erasure token with Tinybird's required `DATASOURCES:CREATE` scope, no read/deploy scopes, protected as a high-impact operational secret until Tinybird offers resource-scoped row deletion; + - deployment token used only by the controlled production release path. +4. Set these Vercel production variables without copying values into logs or artifacts: + - `PRODUCT_ANALYTICS_TINYBIRD_HOST` + - `PRODUCT_ANALYTICS_TINYBIRD_TOKEN` + - `PRODUCT_ANALYTICS_TINYBIRD_READ_TOKEN` + - `PRODUCT_ANALYTICS_TINYBIRD_ERASURE_TOKEN` + - `PRODUCT_ANALYTICS_TINYBIRD_ERASURE_LOOKUP_TOKEN` + - `PRODUCT_ANALYTICS_TINYBIRD_COPY_TOKEN` + - `PRODUCT_ANALYTICS_TINYBIRD_SCHEDULER_TOKEN` + - `PRODUCT_ANALYTICS_INTERNAL_IP_HASHES` + - `CRON_SECRET` + - `NEXTAUTH_SECRET` (existing application secret used to sign the short-lived anonymous browser token; do not create an analytics-specific duplicate) + For a self-hosted release only, set `CAP_ANALYTICS_TRUST_PROXY=1` after verifying that the fronting reverse proxy overwrites `x-forwarded-for`. Omit it on Vercel. +5. Configure the Vercel firewall rule referenced by the collector so `/api/events` has the verified per-IP limit. The collector also applies the same rule by anonymous-ID key. Verify normal navigation is usable, the documented burst test receives `429`, forged forwarding headers do not bypass classification, and preview hostnames are excluded from decision metrics. +6. Deploy the web application and `/api/events` collector through the normal production release process before releasing any desktop, mobile, or CLI build that emits this contract. Confirm the Vercel deployment Git SHA exactly matches the reviewed commit, the database schema attestation matches, and the configured Tinybird workspace and deployment IDs match the reviewed release before sending any test event. +7. Run a uniquely tagged production smoke test only if separately approved. Do not use customer identifiers. Confirm ingestion, durable outbox drain, health, decision deduplication, admin freshness, and strictly scoped cleanup. Record this collector-capable Vercel deployment as the minimum safe web rollback target. +8. Release desktop, mobile, and CLI clients only after the collector smoke test passes. Their durable queues treat collector-absent `404` and retired-contract `410` responses as retryable migration states, retaining the original event and ID across restart rather than dead-lettering it. +9. Enable dashboards only after freshness, duplicates, conflicts, missing identity, clock skew, late events, queue drops, dead letters, and ingestion lag are healthy. Reconcile signup and billing totals against database and Stripe source-of-truth counts before using conversion or revenue decisions. + +Rollback is fail closed. Before any native client release, Vercel may roll back to the recorded pre-collector deployment. After a desktop, mobile, or CLI release can emit the new contract, a pre-collector Vercel deployment is no longer a valid rollback target: Vercel must roll back only to a recorded collector-capable deployment. The native `404` and `410` retry policy protects brief routing races, but bounded queues and the seven-day event-age contract mean it is not authorization for an extended collector outage. Disable provider delivery by removing the app append token when containment is required, and promote the recorded prior Tinybird deployment only when its schema and endpoints remain compatible with the active collector. Do not delete raw data during rollback. Keep reconciliation paused until the retained collector contract is confirmed compatible, retain MySQL and Tinybird health visibility, and record the rollback SHA and deployment IDs. Token rotation follows containment, and erasure credentials must remain available for pending deletion requests. + +## Honest limitations + +- Browser delivery can be dropped by process termination, blockers, or network policy; loss is bounded and observable only when a later request reaches Cap. +- Persistent first-party identity improves retention and attribution analysis but is not cookieless analytics. +- Anonymous traffic is abuse-resistant rather than proof of a human. +- Database reconciliation proves stored signup, share, and collaboration facts; Stripe remains the source of truth for money and subscription state. +- Aggregated actor counts summed across days are actor-days, not period-unique people. The admin UI labels this explicitly. +- Tinybird requires workspace-wide `DATASOURCES:CREATE` for row deletion; Cap narrows accepted predicates and isolates the credential, but the provider cannot technically restrict it to `product_events_v1` today. +- The bounded v2 hot/cold resources are not a production read-path claim until backfill, publication, parity, physical erasure, and cutover gates are implemented and proven. +- Same-workflow recovery cannot run after a GitHub force-cancel or GitHub control-plane outage; recovery then uses the last immutable checkpoint manually. +- No production rollout is authorized by this document or by a green staging workflow. diff --git a/apps/chrome-extension/e2e/analytics-staging.spec.ts b/apps/chrome-extension/e2e/analytics-staging.spec.ts new file mode 100644 index 00000000000..0abca91f074 --- /dev/null +++ b/apps/chrome-extension/e2e/analytics-staging.spec.ts @@ -0,0 +1,502 @@ +import { createHash, createHmac } from "node:crypto"; +import fs from "node:fs"; +import { expect, test } from "@playwright/test"; + +type CapturedEvent = { + eventId: string; + eventName: string; + sessionId: string; + properties?: { is_session_entry?: boolean }; +}; + +const requestEvents = (request: { + postData: () => string | null; +}): CapturedEvent[] => { + try { + const payload = JSON.parse(request.postData() ?? "{}") as { + events?: CapturedEvent[]; + }; + return payload.events ?? []; + } catch { + return []; + } +}; + +const requiredEnvironment = (name: string) => { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +}; + +const percentile = (samples: readonly number[], value: number) => { + if (samples.length === 0) throw new Error("Performance samples are required"); + const sorted = [...samples].sort((left, right) => left - right); + return sorted[Math.ceil((value / 100) * sorted.length) - 1]; +}; + +test("exact-SHA browser tracker preserves sessions, retries, unloads, and matched-control performance", async ({ + browser, +}) => { + const previewUrl = requiredEnvironment("ANALYTICS_PREVIEW_URL"); + const runId = requiredEnvironment("ANALYTICS_BROWSER_RUN_ID"); + const statePath = requiredEnvironment("ANALYTICS_STATE_PATH"); + const artifactPath = requiredEnvironment("ANALYTICS_ARTIFACT_PATH"); + const expectedSha = requiredEnvironment("EXPECTED_SHA"); + const stagingSecret = requiredEnvironment( + "CAP_ANALYTICS_STAGING_TEST_SECRET", + ); + const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim(); + const shareSecret = process.env.VERCEL_PREVIEW_SHARE_SECRET?.trim(); + const context = await browser.newContext({ + baseURL: previewUrl, + extraHTTPHeaders: { + "x-cap-analytics-test-run": runId, + ...(bypass + ? { + "x-vercel-protection-bypass": bypass, + "x-vercel-set-bypass-cookie": "true", + } + : {}), + }, + }); + if (shareSecret) { + const shareUrl = new URL("/api/analytics/staging-test/attest", previewUrl); + shareUrl.searchParams.set("_vercel_share", shareSecret); + const bootstrap = await context.request.get(shareUrl.toString(), { + maxRedirects: 0, + }); + expect([302, 303, 307, 308]).toContain(bootstrap.status()); + } + const attestExactSha = async () => { + const stagingSignature = createHmac("sha256", stagingSecret) + .update(`${runId}:${expectedSha}`) + .digest("hex"); + const response = await context.request.post( + "/api/analytics/staging-test/attest", + { + data: { runId, sha: expectedSha }, + headers: { + Authorization: `Bearer ${stagingSecret}`, + "x-cap-analytics-staging-signature": stagingSignature, + }, + }, + ); + expect(response.ok()).toBe(true); + const payload = (await response.json()) as { sha?: string }; + expect(payload.sha).toBe(expectedSha); + }; + await attestExactSha(); + const captured: CapturedEvent[] = []; + const acceptedEventIds = new Set(); + const benchmarkEventIds = new Set(); + let acceptedRequests = 0; + let failedRequests = 0; + let benchmarking = false; + context.on("request", (request) => { + if (!request.url().endsWith("/api/events") || request.method() !== "POST") { + return; + } + const events = requestEvents(request); + if ( + benchmarking || + events.some((event) => benchmarkEventIds.has(event.eventId)) + ) { + return; + } + captured.push(...events); + }); + context.on("response", (response) => { + if ( + response.url().endsWith("/api/events") && + response.request().method() === "POST" + ) { + const events = requestEvents(response.request()); + if (events.some((event) => benchmarkEventIds.has(event.eventId))) return; + if (response.ok()) { + acceptedRequests += 1; + for (const event of events) { + acceptedEventIds.add(event.eventId); + } + } + } + }); + context.on("requestfailed", (request) => { + if (request.url().endsWith("/api/events")) failedRequests += 1; + }); + + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + await cdp.send("Performance.enable"); + const taskDuration = async () => { + const metrics = await cdp.send("Performance.getMetrics"); + return ( + metrics.metrics.find((metric) => metric.name === "TaskDuration")?.value ?? + 0 + ); + }; + const beforeTaskDuration = await taskDuration(); + await page.goto("/?utm_source=staging-browser&utm_medium=e2e", { + waitUntil: "networkidle", + }); + await expect + .poll( + () => captured.filter((event) => event.eventName === "page_view").length, + ) + .toBeGreaterThanOrEqual(1); + const firstPageView = captured.find( + (event) => event.eventName === "page_view", + ); + expect(firstPageView?.properties?.is_session_entry).toBe(true); + + await page.reload({ waitUntil: "networkidle" }); + await expect + .poll( + () => captured.filter((event) => event.eventName === "page_view").length, + ) + .toBeGreaterThanOrEqual(2); + const reloadPageView = captured + .filter((event) => event.eventName === "page_view") + .at(-1); + expect(reloadPageView?.sessionId).toBe(firstPageView?.sessionId); + expect(reloadPageView?.properties?.is_session_entry).toBe(false); + + await page.evaluate(() => { + const key = "cap_analytics_session_v2"; + const value = JSON.parse(localStorage.getItem(key) ?? "null") as { + lastActivityAt?: number; + } | null; + if (!value || typeof value.lastActivityAt !== "number") { + throw new Error("Browser analytics session was not persisted"); + } + value.lastActivityAt = Date.now() - 29 * 60 * 1_000; + localStorage.setItem(key, JSON.stringify(value)); + }); + await page.reload({ waitUntil: "networkidle" }); + await expect + .poll( + () => captured.filter((event) => event.eventName === "page_view").length, + ) + .toBeGreaterThanOrEqual(3); + const activePageView = captured + .filter((event) => event.eventName === "page_view") + .at(-1); + expect(activePageView?.sessionId).toBe(firstPageView?.sessionId); + + const secondPage = await context.newPage(); + await secondPage.goto("/pricing", { waitUntil: "networkidle" }); + await expect + .poll( + () => captured.filter((event) => event.eventName === "page_view").length, + ) + .toBeGreaterThanOrEqual(4); + const secondTabPageView = captured + .filter((event) => event.eventName === "page_view") + .at(-1); + expect(secondTabPageView?.sessionId).toBe(firstPageView?.sessionId); + await secondPage.close(); + + await page.evaluate(() => { + const key = "cap_analytics_session_v2"; + const value = JSON.parse(localStorage.getItem(key) ?? "null") as { + lastActivityAt?: number; + } | null; + if (!value || typeof value.lastActivityAt !== "number") { + throw new Error("Browser analytics session was not persisted"); + } + value.lastActivityAt = Date.now() - (30 * 60 * 1_000 + 1); + localStorage.setItem(key, JSON.stringify(value)); + }); + await page.reload({ waitUntil: "networkidle" }); + await expect + .poll( + () => captured.filter((event) => event.eventName === "page_view").length, + ) + .toBeGreaterThanOrEqual(5); + const returnedPageView = captured + .filter((event) => event.eventName === "page_view") + .at(-1); + expect(returnedPageView?.sessionId).not.toBe(firstPageView?.sessionId); + expect(returnedPageView?.properties?.is_session_entry).toBe(true); + + let abortNextCollectorRequest = true; + await context.route("**/api/events", async (route) => { + if (abortNextCollectorRequest) { + abortNextCollectorRequest = false; + await route.abort("internetdisconnected"); + return; + } + await route.continue(); + }); + await page + .locator('a[href="/pricing"]') + .first() + .evaluate((element) => { + (element as HTMLAnchorElement).click(); + }); + await expect(page).toHaveURL(/\/pricing/); + await expect + .poll(() => failedRequests, { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1); + await context.unroute("**/api/events"); + await expect + .poll(() => acceptedEventIds.size, { timeout: 15_000 }) + .toBeGreaterThanOrEqual(6); + await page.evaluate(() => window.dispatchEvent(new Event("pointerdown"))); + await page.waitForTimeout(100); + await page.evaluate(() => + window.dispatchEvent(new PageTransitionEvent("pagehide")), + ); + await expect + .poll( + () => + captured + .filter((event) => event.eventName === "page_engagement") + .some((event) => acceptedEventIds.has(event.eventId)), + { timeout: 15_000 }, + ) + .toBe(true); + + const afterTaskDuration = await taskDuration(); + const interactionTaskDurationMs = Math.max( + 0, + Math.round((afterTaskDuration - beforeTaskDuration) * 1_000), + ); + const taskDurationBudgetMs = Number( + process.env.ANALYTICS_BROWSER_TASK_BUDGET_MS ?? 1_500, + ); + expect(interactionTaskDurationMs).toBeLessThanOrEqual(taskDurationBudgetMs); + + const captureSampleCount = 30; + let benchmarkCapturedEvents = 0; + let benchmarkCapturedEngagementEvents = 0; + await context.route("**/api/events", async (route) => { + const events = requestEvents(route.request()); + benchmarkCapturedEvents += events.length; + benchmarkCapturedEngagementEvents += events.filter( + (event) => event.eventName === "page_engagement", + ).length; + for (const event of events) benchmarkEventIds.add(event.eventId); + await route.fulfill({ + body: JSON.stringify({ accepted: events.length }), + contentType: "application/json", + status: 200, + }); + }); + benchmarking = true; + const longTaskSupported = await page.evaluate(() => { + const benchmarkWindow = window as typeof window & { + __capAnalyticsLongTaskObserver?: PerformanceObserver; + __capAnalyticsLongTasks?: Array<{ duration: number; startTime: number }>; + }; + benchmarkWindow.__capAnalyticsLongTaskObserver?.disconnect(); + benchmarkWindow.__capAnalyticsLongTasks = []; + if (!PerformanceObserver.supportedEntryTypes.includes("longtask")) { + return false; + } + const observer = new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + benchmarkWindow.__capAnalyticsLongTasks?.push({ + duration: entry.duration, + startTime: entry.startTime, + }); + } + }); + observer.observe({ type: "longtask" }); + benchmarkWindow.__capAnalyticsLongTaskObserver = observer; + Object.defineProperty(navigator, "sendBeacon", { + configurable: true, + value: () => false, + }); + window.addEventListener("cap-analytics-control-pointerdown", () => {}, { + passive: true, + }); + window.addEventListener("cap-analytics-control-pagehide", () => {}, { + passive: true, + }); + return true; + }); + expect(longTaskSupported).toBe(true); + type DispatchMeasurement = { + durationMs: number; + endedAt: number; + startedAt: number; + }; + const dispatchMeasurement = async ( + control: boolean, + event: "pagehide" | "pointerdown", + ): Promise => + page.evaluate( + ({ control, event }) => { + const startedAt = performance.now(); + if (control) { + window.dispatchEvent( + event === "pagehide" + ? new PageTransitionEvent("cap-analytics-control-pagehide") + : new Event("cap-analytics-control-pointerdown"), + ); + } else if (event === "pagehide") { + window.dispatchEvent(new PageTransitionEvent("pagehide")); + } else { + window.dispatchEvent(new Event("pointerdown")); + } + const endedAt = performance.now(); + return { durationMs: endedAt - startedAt, endedAt, startedAt }; + }, + { control, event }, + ); + const captureMeasurements: number[] = []; + const controlMeasurements: number[] = []; + const captureWindows: Array<{ endedAt: number; startedAt: number }> = []; + const controlWindows: Array<{ endedAt: number; startedAt: number }> = []; + const measureCapture = async (control: boolean) => { + const pointer = await dispatchMeasurement(control, "pointerdown"); + await page.waitForTimeout(5); + const pagehide = await dispatchMeasurement(control, "pagehide"); + const windows = control ? controlWindows : captureWindows; + windows.push(pointer, pagehide); + return pointer.durationMs + pagehide.durationMs; + }; + for (let index = 0; index < captureSampleCount; index += 1) { + if (index % 2 === 0) { + controlMeasurements.push(await measureCapture(true)); + captureMeasurements.push(await measureCapture(false)); + } else { + captureMeasurements.push(await measureCapture(false)); + controlMeasurements.push(await measureCapture(true)); + } + await page.waitForTimeout(10); + } + await expect + .poll(() => benchmarkCapturedEngagementEvents, { timeout: 15_000 }) + .toBeGreaterThanOrEqual(captureSampleCount); + await page.waitForTimeout(100); + const longTasks = await page.evaluate(() => { + const benchmarkWindow = window as typeof window & { + __capAnalyticsLongTaskObserver?: PerformanceObserver; + __capAnalyticsLongTasks?: Array<{ duration: number; startTime: number }>; + }; + benchmarkWindow.__capAnalyticsLongTaskObserver?.disconnect(); + return benchmarkWindow.__capAnalyticsLongTasks ?? []; + }); + benchmarking = false; + await context.unroute("**/api/events"); + const overlapsWindow = ( + entry: { duration: number; startTime: number }, + windows: ReadonlyArray<{ endedAt: number; startedAt: number }>, + ) => + windows.some( + (window) => + entry.startTime < window.endedAt && + entry.startTime + entry.duration > window.startedAt, + ); + const captureLongTasks = longTasks.filter((entry) => + overlapsWindow(entry, captureWindows), + ); + const controlLongTasks = longTasks.filter((entry) => + overlapsWindow(entry, controlWindows), + ); + const perEventDeltas = captureMeasurements.map((duration, index) => + Math.max(0, duration - controlMeasurements[index]), + ); + const captureP50Ms = percentile(perEventDeltas, 50); + const captureP95Ms = percentile(perEventDeltas, 95); + const captureP99Ms = percentile(perEventDeltas, 99); + const captureP95BudgetMs = Number( + process.env.ANALYTICS_BROWSER_CAPTURE_P95_BUDGET_MS ?? 2, + ); + const captureP99BudgetMs = Number( + process.env.ANALYTICS_BROWSER_CAPTURE_P99_BUDGET_MS ?? 5, + ); + const additionalLongTaskBudget = Number( + process.env.ANALYTICS_BROWSER_LONG_TASK_BUDGET ?? 0, + ); + expect(captureMeasurements).toHaveLength(captureSampleCount); + expect(controlMeasurements).toHaveLength(captureSampleCount); + expect(captureP95Ms).toBeLessThanOrEqual(captureP95BudgetMs); + expect(captureP99Ms).toBeLessThanOrEqual(captureP99BudgetMs); + expect(captureLongTasks.length).toBeLessThanOrEqual( + controlLongTasks.length + additionalLongTaskBudget, + ); + const uniqueEventIds = new Set(captured.map((event) => event.eventId)); + expect(uniqueEventIds.size).toBeGreaterThanOrEqual(6); + expect( + [...uniqueEventIds].every((eventId) => acceptedEventIds.has(eventId)), + ).toBe(true); + expect(captured.some((event) => event.eventName === "page_engagement")).toBe( + true, + ); + const anonymousId = await page.evaluate(() => + localStorage.getItem("cap_analytics_anonymous_id_v1"), + ); + if (!anonymousId) { + throw new Error("Browser analytics anonymous identity was not persisted"); + } + + const state = JSON.parse(fs.readFileSync(statePath, "utf8")) as Record< + string, + unknown + >; + state.browserExpectedEvents = acceptedEventIds.size; + state.browserAnonymousIdentityHash = createHash("sha256") + .update(`anonymous\0${anonymousId}`) + .digest("hex"); + fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, { + mode: 0o600, + }); + const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8")) as Record< + string, + unknown + > & { assertions?: Record }; + artifact.browser = { + acceptedRequests, + failedRequests, + uniqueEvents: acceptedEventIds.size, + pageViews: captured.filter((event) => event.eventName === "page_view") + .length, + engagementEvents: captured.filter( + (event) => event.eventName === "page_engagement", + ).length, + sameTabReloadPassed: true, + multiTabSessionPassed: true, + activityAt29MinutesPassed: true, + inactivityBoundaryPassed: true, + offlineRetryPassed: true, + unloadPassed: true, + interactionTaskDurationMs, + taskDurationBudgetMs, + capturePerformance: { + capturedEngagementEvents: benchmarkCapturedEngagementEvents, + capturedEvents: benchmarkCapturedEvents, + captureP50Ms, + captureP95BudgetMs, + captureP95Ms, + captureP99BudgetMs, + captureP99Ms, + captureSamples: captureMeasurements, + controlP50Ms: percentile(controlMeasurements, 50), + controlP95Ms: percentile(controlMeasurements, 95), + controlP99Ms: percentile(controlMeasurements, 99), + controlSamples: controlMeasurements, + additionalLongTaskBudget, + captureLongTaskCount: captureLongTasks.length, + captureLongTaskMaxDurationMs: Math.max( + 0, + ...captureLongTasks.map((entry) => entry.duration), + ), + controlLongTaskCount: controlLongTasks.length, + controlLongTaskMaxDurationMs: Math.max( + 0, + ...controlLongTasks.map((entry) => entry.duration), + ), + sampleCount: captureSampleCount, + }, + }; + artifact.assertions = { + ...(artifact.assertions ?? {}), + deployedBrowserTrackerPassed: true, + browserMainThreadBudgetPassed: true, + }; + fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`); + await attestExactSha(); + await context.close(); +}); diff --git a/apps/cli/src/upload.rs b/apps/cli/src/upload.rs index fc9d49a6a9e..4665f525dd0 100644 --- a/apps/cli/src/upload.rs +++ b/apps/cli/src/upload.rs @@ -267,6 +267,7 @@ async fn upload_file_with_agent( Method::POST, "/uploads", &json!({ + "initiatingPlatform": "cli", "fileName": file_name, "contentType": "video/mp4", "contentLength": content_length, @@ -402,6 +403,7 @@ async fn create_video( } let mut params: Vec<(&str, String)> = vec![ + ("initiatingPlatform", "cli".to_string()), ("recordingMode", "desktopMP4".to_string()), ("durationInSecs", meta.duration_in_secs.to_string()), ("width", meta.width.to_string()), 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..479fe041e02 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -64,6 +64,8 @@ rodio = "0.19.0" png = "0.17.13" device_query = "4.0.1" base64 = "0.22.1" +keyring = "4.1.5" +ring = "0.17.14" reqwest = { version = "0.12.24", features = ["json", "stream", "multipart"] } dotenvy_macro = "0.15.7" global-hotkey = "0.7.0" @@ -120,7 +122,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..e7dc285e092 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(app, 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(app, 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..9728a6246b0 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() @@ -4835,6 +4832,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { cli::get_cli_install_status, cli::install_cli, cli::uninstall_cli, + product_analytics::capture_client_product_analytics_event, recording::start_recording, recording::stop_recording, recording::pause_recording, @@ -5201,6 +5199,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 +5303,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..2c88c2bbb9e --- /dev/null +++ b/apps/desktop/src-tauri/src/product_analytics.rs @@ -0,0 +1,2529 @@ +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use ring::{ + aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}, + rand::{SecureRandom, SystemRandom}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::{ + collections::HashSet, + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + sync::{ + Mutex, OnceLock, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Duration, +}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_store::StoreExt; +use tokio::sync::mpsc; +use tracing::{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_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"; +const PRODUCT_EVENT_OUTBOX_STORE_KEY: &str = "product_analytics_outbox_v1"; +const PRODUCT_EVENT_OUTBOX_RECOVERY_STORE_KEY: &str = "product_analytics_outbox_recovery_v1"; +const PRODUCT_EVENT_OUTBOX_LEGACY_KEY_STORE_KEY: &str = "product_analytics_outbox_fallback_key_v1"; +const PRODUCT_EVENT_OUTBOX_KEY_FILE: &str = "product-analytics-outbox-key-v1"; +const PRODUCT_EVENT_OUTBOX_JOURNAL_FILE: &str = "product-analytics-outbox-journal-v1"; +const PRODUCT_EVENT_OUTBOX_KEYRING_SERVICE: &str = "so.cap.desktop"; +const PRODUCT_EVENT_OUTBOX_KEYRING_USER: &str = "product-analytics-outbox-v1"; +const PRODUCT_EVENT_OUTBOX_LEGACY_KEYRING_USER: &str = "product-analytics-outbox-v1-backup"; +const PRODUCT_EVENT_OUTBOX_CAPACITY: usize = 500; +const PRODUCT_EVENT_DEAD_LETTER_CAPACITY: usize = 100; +const PRODUCT_EVENT_LOSS_SUMMARY_CAPACITY: usize = 100; +const PRODUCT_EVENT_MAX_RETRY_DELAY: Duration = Duration::from_secs(5 * 60); + +#[derive(Debug)] +pub enum ProductAnalyticsEvent { + AnalyticsDeliveryLoss { + failure_class: String, + failed_event_name: String, + status: Option, + count: u64, + first_sequence: u64, + last_sequence: u64, + first_failed_at_ms: i64, + last_failed_at_ms: i64, + }, + 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, + }, + RecordingStartFailed { + mode: &'static str, + error: String, + }, + 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 +} + +fn classify_failure(error: &str) -> &'static str { + let normalized = error.to_lowercase(); + if normalized.contains("timeout") || normalized.contains("timed out") { + "timeout" + } else if normalized.contains("permission") || normalized.contains("denied") { + "permission" + } else if normalized.contains("network") || normalized.contains("connect") { + "network" + } else if normalized.contains("disk") || normalized.contains("storage") { + "storage" + } else if normalized.contains("codec") + || normalized.contains("format") + || normalized.contains("media") + { + "invalid_media" + } else { + "unknown" + } +} + +#[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::AnalyticsDeliveryLoss { + failure_class, + failed_event_name, + status, + count, + first_sequence, + last_sequence, + first_failed_at_ms, + last_failed_at_ms, + } => { + let mut data = EventData::new("analytics_delivery_loss"); + data.set("failure_class", failure_class); + data.set("failed_event_name", failed_event_name); + data.set("status", status); + data.set("count", count); + data.set("first_sequence", first_sequence); + data.set("last_sequence", last_sequence); + data.set("first_failed_at_ms", first_failed_at_ms); + data.set("last_failed_at_ms", last_failed_at_ms); + data + } + 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("failure_class", classify_failure(&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::RecordingStartFailed { mode, error } => { + let mut data = EventData::new("recording_start_failed"); + data.set("mode", mode); + data.set("failure_class", classify_failure(&error)); + 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("failure_class", classify_failure(&reason)); + data + } + } +} + +fn is_core_product_event(name: &str) -> bool { + matches!( + name, + "recording_started" + | "recording_completed" + | "recording_start_failed" + | "multipart_upload_complete" + | "multipart_upload_failed" + | "recording_recovery_failed" + | "analytics_delivery_loss" + ) +} + +fn desktop_client_product_event_name(name: &str) -> Option<&'static str> { + match name { + "user_signed_in" => Some("user_signed_in"), + "user_signed_out" => Some("user_signed_out"), + "recording_started" => Some("recording_started"), + "recording_start_failed" => Some("recording_start_failed"), + "recording_completed" => Some("recording_completed"), + "multipart_upload_complete" => Some("multipart_upload_complete"), + "multipart_upload_failed" => Some("multipart_upload_failed"), + "recording_recovery_failed" => Some("recording_recovery_failed"), + "export_button_clicked" => Some("export_button_clicked"), + "export_fps_changed" => Some("export_fps_changed"), + "export_started" => Some("export_started"), + "export_completed" => Some("export_completed"), + "export_failed" => Some("export_failed"), + "create_shareable_link_clicked" => Some("create_shareable_link_clicked"), + "camera_selected" => Some("camera_selected"), + "microphone_selected" => Some("microphone_selected"), + "screenshot_view_clicked" => Some("screenshot_view_clicked"), + "screenshot_editor_clicked" => Some("screenshot_editor_clicked"), + "screenshot_folder_clicked" => Some("screenshot_folder_clicked"), + "screenshot_copy_clicked" => Some("screenshot_copy_clicked"), + "screenshot_share_clicked" => Some("screenshot_share_clicked"), + "recording_view_clicked" => Some("recording_view_clicked"), + "recording_folder_clicked" => Some("recording_folder_clicked"), + "recording_copy_clicked" => Some("recording_copy_clicked"), + "recording_editor_clicked" => Some("recording_editor_clicked"), + "experiment_exposed" => Some("experiment_exposed"), + _ => None, + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProductEvent { + event_id: String, + event_name: String, + occurred_at: String, + anonymous_id: String, + session_id: String, + platform: String, + app_version: String, + 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.to_string(), + 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".to_string(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + 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, + }) +} + +#[derive(Debug)] +struct DeliveryError { + retryable: bool, + status: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProductEventAdmissionResponse { + accepted: usize, + accepted_event_ids: Option>, + rejected_event_ids: Option>, +} + +#[derive(Debug, Eq, PartialEq)] +struct ProductEventAdmission { + accepted_event_ids: HashSet, + rejected_event_ids: HashSet, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProductEventBatchKind { + LossReport, + Pending, + DeadLetterRetry, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ProductEventDeadLetter { + event: ProductEvent, + failure_class: String, + status: Option, + failed_at: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ProductEventLossSummary { + summary_id: String, + failure_class: String, + failed_event_name: String, + platform: String, + app_version: String, + anonymous_id: String, + session_id: String, + status: Option, + count: u64, + first_sequence: u64, + last_sequence: u64, + first_failed_at_ms: i64, + last_failed_at_ms: i64, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +struct ProductEventOutbox { + pending: Vec, + dead_letters: Vec, + loss_summaries: Vec, + loss_reports_in_flight: Vec, + next_delivery_sequence: u64, +} + +async fn send_product_batch_once( + app: &AppHandle, + events: &[ProductEvent], +) -> Result { + if !live_telemetry_enabled(app) { + return Ok(ProductEventAdmission { + accepted_event_ids: events.iter().map(|event| event.event_id.clone()).collect(), + rejected_event_ids: HashSet::new(), + }); + } + + 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(|_| DeliveryError { + retryable: true, + status: None, + })?; + + if response.status().is_success() { + let status = response.status().as_u16(); + let payload = response + .json::() + .await + .map_err(|_| DeliveryError { + retryable: true, + status: Some(status), + })?; + parse_product_event_admission(events, payload).ok_or(DeliveryError { + retryable: true, + status: Some(status), + }) + } else { + let status = response.status().as_u16(); + Err(DeliveryError { + retryable: should_retry_product_status(status), + status: Some(status), + }) + } +} + +fn parse_product_event_admission( + events: &[ProductEvent], + response: ProductEventAdmissionResponse, +) -> Option { + let requested_event_ids = events + .iter() + .map(|event| event.event_id.clone()) + .collect::>(); + match (response.accepted_event_ids, response.rejected_event_ids) { + (Some(accepted_event_ids), Some(rejected_event_ids)) => { + let accepted_event_ids = accepted_event_ids.into_iter().collect::>(); + let rejected_event_ids = rejected_event_ids.into_iter().collect::>(); + let accounted_event_ids = accepted_event_ids + .union(&rejected_event_ids) + .cloned() + .collect::>(); + (response.accepted == accepted_event_ids.len() + && accepted_event_ids.is_disjoint(&rejected_event_ids) + && accounted_event_ids == requested_event_ids) + .then_some(ProductEventAdmission { + accepted_event_ids, + rejected_event_ids, + }) + } + (None, None) if response.accepted == requested_event_ids.len() => { + Some(ProductEventAdmission { + accepted_event_ids: requested_event_ids, + rejected_event_ids: HashSet::new(), + }) + } + _ => None, + } +} + +fn should_retry_product_status(status: u16) -> bool { + status == 404 || status == 410 || status == 429 || status >= 500 +} + +fn decode_outbox_encryption_key(encoded: &str) -> Result<[u8; 32], String> { + BASE64 + .decode(encoded.trim()) + .map_err(|_| "invalid_outbox_key".to_string())? + .try_into() + .map_err(|_| "invalid_outbox_key".to_string()) +} + +fn read_keyring_outbox_key(user: &str) -> Result, String> { + let entry = keyring::Entry::new(PRODUCT_EVENT_OUTBOX_KEYRING_SERVICE, user) + .map_err(|_| "outbox_keyring_unavailable".to_string())?; + match entry.get_password() { + Ok(encoded) => decode_outbox_encryption_key(&encoded).map(Some), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err("outbox_keyring_unavailable".to_string()), + } +} + +fn keyring_outbox_encryption_keys() -> Result, String> { + let mut keys = Vec::new(); + let mut failure = None; + for user in [ + PRODUCT_EVENT_OUTBOX_KEYRING_USER, + PRODUCT_EVENT_OUTBOX_LEGACY_KEYRING_USER, + ] { + match read_keyring_outbox_key(user) { + Ok(Some(key)) if !keys.contains(&key) => keys.push(key), + Ok(_) => {} + Err(error) => failure = Some(error), + } + } + if keys.is_empty() + && let Some(error) = failure + { + return Err(error); + } + Ok(keys) +} + +fn persist_keyring_outbox_key(user: &str, key: [u8; 32]) -> Result<(), String> { + let entry = keyring::Entry::new(PRODUCT_EVENT_OUTBOX_KEYRING_SERVICE, user) + .map_err(|_| "outbox_keyring_unavailable".to_string())?; + entry + .set_password(&BASE64.encode(key)) + .map_err(|_| "outbox_keyring_write_failed".to_string()) +} + +fn file_outbox_key_path(app: &AppHandle) -> Result { + app.path() + .app_local_data_dir() + .map(|directory| directory.join(PRODUCT_EVENT_OUTBOX_KEY_FILE)) + .map_err(|_| "outbox_key_directory_unavailable".to_string()) +} + +fn outbox_journal_path(app: &AppHandle) -> Result { + app.path() + .app_local_data_dir() + .map(|directory| directory.join(PRODUCT_EVENT_OUTBOX_JOURNAL_FILE)) + .map_err(|_| "outbox_journal_directory_unavailable".to_string()) +} + +fn append_event_journal(app: &AppHandle, event: &ProductEvent) -> Result<(), String> { + let path = outbox_journal_path(app)?; + let encryption_key = outbox_encryption_key(app)?; + append_encrypted_event_journal(&path, event, encryption_key).map(|_| ()) +} + +fn append_encrypted_event_journal( + path: &Path, + event: &ProductEvent, + encryption_key: &[u8; 32], +) -> Result { + let parent = path + .parent() + .ok_or_else(|| "outbox_journal_directory_unavailable".to_string())?; + fs::create_dir_all(parent).map_err(|_| "outbox_journal_directory_unavailable".to_string())?; + let encoded = encrypt_outbox_with_key( + &ProductEventOutbox { + pending: vec![event.clone()], + ..ProductEventOutbox::default() + }, + encryption_key, + )?; + let appended_bytes = encoded.len().saturating_add(1); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|_| "outbox_journal_write_failed".to_string())?; + #[cfg(unix)] + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| "outbox_journal_write_failed".to_string())?; + file.write_all(encoded.as_bytes()) + .and_then(|()| file.write_all(b"\n")) + .and_then(|()| file.sync_data()) + .map_err(|_| "outbox_journal_write_failed".to_string())?; + Ok(appended_bytes) +} + +fn load_event_journal(app: &AppHandle) -> Result<(ProductEventOutbox, bool), String> { + let path = outbox_journal_path(app)?; + let value = match fs::read_to_string(path) { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok((ProductEventOutbox::default(), false)); + } + Err(_) => return Err("outbox_journal_read_failed".to_string()), + }; + let mut outbox = ProductEventOutbox::default(); + let mut corrupt = false; + for encoded in value.lines().filter(|line| !line.is_empty()) { + match decrypt_outbox(app, encoded) { + Ok(record) => merge_outbox(&mut outbox, record), + Err(_) => corrupt = true, + } + } + Ok((outbox, corrupt)) +} + +fn truncate_event_journal(app: &AppHandle) -> Result<(), String> { + let path = outbox_journal_path(app)?; + let parent = path + .parent() + .ok_or_else(|| "outbox_journal_directory_unavailable".to_string())?; + fs::create_dir_all(parent).map_err(|_| "outbox_journal_directory_unavailable".to_string())?; + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path) + .map_err(|_| "outbox_journal_write_failed".to_string())?; + #[cfg(unix)] + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| "outbox_journal_write_failed".to_string())?; + file.sync_all() + .map_err(|_| "outbox_journal_write_failed".to_string()) +} + +fn delete_event_journal(app: &AppHandle) -> Result<(), String> { + match fs::remove_file(outbox_journal_path(app)?) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("outbox_journal_delete_failed".to_string()), + } +} + +fn read_file_outbox_key(path: &Path) -> Result, String> { + match fs::read_to_string(path) { + Ok(encoded) => decode_outbox_encryption_key(&encoded).map(Some), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err("outbox_key_file_unavailable".to_string()), + } +} + +fn initialize_file_outbox_key(path: &Path, key: [u8; 32]) -> Result<[u8; 32], String> { + if let Some(existing) = read_file_outbox_key(path)? { + return Ok(existing); + } + let parent = path + .parent() + .ok_or_else(|| "outbox_key_directory_unavailable".to_string())?; + fs::create_dir_all(parent).map_err(|_| "outbox_key_directory_unavailable".to_string())?; + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .map_err(|_| "outbox_key_file_write_failed".to_string())?; + #[cfg(unix)] + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| "outbox_key_file_write_failed".to_string())?; + temporary + .write_all(BASE64.encode(key).as_bytes()) + .and_then(|()| temporary.as_file().sync_all()) + .map_err(|_| "outbox_key_file_write_failed".to_string())?; + match temporary.persist_noclobber(path) { + Ok(file) => { + file.sync_all() + .map_err(|_| "outbox_key_file_write_failed".to_string())?; + #[cfg(unix)] + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| "outbox_key_file_write_failed".to_string())?; + Ok(key) + } + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { + read_file_outbox_key(path)?.ok_or_else(|| "outbox_key_file_unavailable".to_string()) + } + Err(_) => Err("outbox_key_file_write_failed".to_string()), + } +} + +fn decode_legacy_outbox_encryption_keys(value: &Value) -> Result, String> { + if let Some(encoded) = value.as_str() { + return decode_outbox_encryption_key(encoded).map(|key| vec![key]); + } + value + .as_array() + .ok_or_else(|| "invalid_legacy_outbox_key".to_string())? + .iter() + .map(|value| { + value + .as_str() + .ok_or_else(|| "invalid_legacy_outbox_key".to_string()) + .and_then(decode_outbox_encryption_key) + }) + .collect() +} + +fn legacy_store_outbox_encryption_keys(app: &AppHandle) -> Result, String> { + let store = app + .store("store") + .map_err(|_| "legacy_outbox_key_store_unavailable".to_string())?; + match store.get(PRODUCT_EVENT_OUTBOX_LEGACY_KEY_STORE_KEY) { + Some(value) => decode_legacy_outbox_encryption_keys(&value), + None => Ok(Vec::new()), + } +} + +fn delete_legacy_store_outbox_encryption_keys(app: &AppHandle) -> Result<(), String> { + let store = app + .store("store") + .map_err(|_| "legacy_outbox_key_store_unavailable".to_string())?; + store.delete(PRODUCT_EVENT_OUTBOX_LEGACY_KEY_STORE_KEY); + store + .save() + .map_err(|_| "legacy_outbox_key_store_write_failed".to_string()) +} + +fn outbox_encryption_keys(app: &AppHandle) -> Result, String> { + let mut keys = Vec::new(); + let mut failure = None; + if let Some(key) = PRODUCT_EVENT_OUTBOX_ENCRYPTION_KEY.get() { + keys.push(*key); + } + match file_outbox_key_path(app).and_then(|path| read_file_outbox_key(&path)) { + Ok(Some(key)) if !keys.contains(&key) => keys.push(key), + Ok(_) => {} + Err(error) => failure = Some(error), + } + match keyring_outbox_encryption_keys() { + Ok(keyring_keys) => { + for key in keyring_keys { + if !keys.contains(&key) { + keys.push(key); + } + } + } + Err(error) => failure = Some(error), + } + match legacy_store_outbox_encryption_keys(app) { + Ok(legacy_keys) => { + for key in legacy_keys { + if !keys.contains(&key) { + keys.push(key); + } + } + } + Err(error) => failure = Some(error), + } + if keys.is_empty() + && let Some(error) = failure + { + return Err(error); + } + Ok(keys) +} + +fn outbox_encryption_key(app: &AppHandle) -> Result<&'static [u8; 32], String> { + if let Some(key) = PRODUCT_EVENT_OUTBOX_ENCRYPTION_KEY.get() { + return Ok(key); + } + + let key_path = file_outbox_key_path(app)?; + let candidate = if let Some(key) = read_file_outbox_key(&key_path)? { + key + } else if let Some(key) = keyring_outbox_encryption_keys().unwrap_or_default().first() { + *key + } else { + let mut generated = [0_u8; 32]; + SystemRandom::new() + .fill(&mut generated) + .map_err(|_| "key_generation_failed".to_string())?; + generated + }; + let key = initialize_file_outbox_key(&key_path, candidate)?; + let _ = persist_keyring_outbox_key(PRODUCT_EVENT_OUTBOX_KEYRING_USER, key); + let _ = PRODUCT_EVENT_OUTBOX_ENCRYPTION_KEY.set(key); + PRODUCT_EVENT_OUTBOX_ENCRYPTION_KEY + .get() + .ok_or_else(|| "key_cache_failed".to_string()) +} + +fn encrypt_outbox(app: &AppHandle, outbox: &ProductEventOutbox) -> Result { + encrypt_outbox_with_key(outbox, outbox_encryption_key(app)?) +} + +fn encrypt_outbox_with_key( + outbox: &ProductEventOutbox, + encryption_key: &[u8; 32], +) -> Result { + let key = LessSafeKey::new( + UnboundKey::new(&AES_256_GCM, encryption_key) + .map_err(|_| "encryption_key_failed".to_string())?, + ); + let mut nonce_bytes = [0_u8; 12]; + SystemRandom::new() + .fill(&mut nonce_bytes) + .map_err(|_| "nonce_generation_failed".to_string())?; + let mut encrypted = + serde_json::to_vec(outbox).map_err(|_| "outbox_serialization_failed".to_string())?; + key.seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce_bytes), + Aad::from(b"cap-product-analytics-outbox-v1".as_slice()), + &mut encrypted, + ) + .map_err(|_| "outbox_encryption_failed".to_string())?; + let mut stored = nonce_bytes.to_vec(); + stored.extend(encrypted); + Ok(BASE64.encode(stored)) +} + +fn decrypt_outbox(app: &AppHandle, value: &str) -> Result { + if let Some(key) = PRODUCT_EVENT_OUTBOX_ENCRYPTION_KEY.get() + && let Ok(outbox) = decrypt_outbox_with_key(value, key) + { + return Ok(outbox); + } + + let candidates = outbox_encryption_keys(app)?; + decrypt_outbox_with_keys(value, &candidates).map(|(outbox, _)| outbox) +} + +fn decrypt_outbox_with_keys( + value: &str, + candidates: &[[u8; 32]], +) -> Result<(ProductEventOutbox, [u8; 32]), String> { + if candidates.is_empty() { + return Err("outbox_key_unavailable".to_string()); + } + for key in candidates { + if let Ok(outbox) = decrypt_outbox_with_key(value, key) { + return Ok((outbox, *key)); + } + } + Err("outbox_decryption_failed".to_string()) +} + +fn decrypt_outbox_with_key( + value: &str, + encryption_key: &[u8; 32], +) -> Result { + let stored = BASE64 + .decode(value) + .map_err(|_| "outbox_decode_failed".to_string())?; + if stored.len() <= 12 { + return Err("outbox_decode_failed".to_string()); + } + let (nonce, encrypted) = stored.split_at(12); + let nonce_bytes: [u8; 12] = nonce + .try_into() + .map_err(|_| "outbox_decode_failed".to_string())?; + let key = LessSafeKey::new( + UnboundKey::new(&AES_256_GCM, encryption_key) + .map_err(|_| "encryption_key_failed".to_string())?, + ); + let mut decrypted = encrypted.to_vec(); + let plaintext = key + .open_in_place( + Nonce::assume_unique_for_key(nonce_bytes), + Aad::from(b"cap-product-analytics-outbox-v1".as_slice()), + &mut decrypted, + ) + .map_err(|_| "outbox_decryption_failed".to_string())?; + serde_json::from_slice(plaintext).map_err(|_| "outbox_deserialization_failed".to_string()) +} + +fn write_outbox( + app: &AppHandle, + store_key: &str, + outbox: &ProductEventOutbox, +) -> Result<(), String> { + let encrypted = encrypt_outbox(app, outbox)?; + let store = app + .store("store") + .map_err(|_| "store_unavailable".to_string())?; + store.set(store_key, encrypted); + store.save().map_err(|_| "store_write_failed".to_string()) +} + +fn delete_stored_outbox(app: &AppHandle, store_key: &str) -> Result<(), String> { + let store = app + .store("store") + .map_err(|_| "store_unavailable".to_string())?; + store.delete(store_key); + store.save().map_err(|_| "store_write_failed".to_string()) +} + +fn load_stored_outbox(app: &AppHandle, store_key: &str) -> Result { + let store = app + .store("store") + .map_err(|_| "store_unavailable".to_string())?; + let Some(value) = store.get(store_key) else { + return Ok(ProductEventOutbox::default()); + }; + let Some(encrypted) = value.as_str() else { + return Err("invalid_stored_outbox".to_string()); + }; + decrypt_outbox(app, encrypted) +} + +fn loss_summary_matches( + summary: &ProductEventLossSummary, + failure_class: &str, + event: &ProductEvent, + status: Option, +) -> bool { + summary.failure_class == failure_class + && summary.failed_event_name == event.event_name + && summary.platform == event.platform + && summary.app_version == event.app_version + && summary.status == status + && summary.anonymous_id == event.anonymous_id + && summary.session_id == event.session_id +} + +fn is_capacity_loss_summary(summary: &ProductEventLossSummary) -> bool { + summary.failure_class == "summary_capacity" && summary.failed_event_name == "other" +} + +fn capacity_loss_summary(source: ProductEventLossSummary) -> ProductEventLossSummary { + ProductEventLossSummary { + summary_id: source.summary_id, + failure_class: "summary_capacity".to_string(), + failed_event_name: "other".to_string(), + platform: "desktop".to_string(), + app_version: "mixed".to_string(), + anonymous_id: source.anonymous_id, + session_id: String::new(), + status: None, + count: source.count, + first_sequence: source.first_sequence, + last_sequence: source.last_sequence, + first_failed_at_ms: source.first_failed_at_ms, + last_failed_at_ms: source.last_failed_at_ms, + } +} + +fn aggregate_loss_summary( + fallback: &mut ProductEventLossSummary, + source: &ProductEventLossSummary, +) { + fallback.count = fallback.count.saturating_add(source.count); + fallback.first_sequence = fallback.first_sequence.min(source.first_sequence); + fallback.last_sequence = fallback.last_sequence.max(source.last_sequence); + fallback.first_failed_at_ms = fallback.first_failed_at_ms.min(source.first_failed_at_ms); + fallback.last_failed_at_ms = fallback.last_failed_at_ms.max(source.last_failed_at_ms); +} + +fn record_delivery_loss( + outbox: &mut ProductEventOutbox, + failure_class: &str, + event: &ProductEvent, + status: Option, + failed_at_ms: i64, +) { + outbox.next_delivery_sequence = outbox.next_delivery_sequence.saturating_add(1); + let sequence = outbox.next_delivery_sequence; + if let Some(summary) = outbox + .loss_summaries + .iter_mut() + .find(|summary| loss_summary_matches(summary, failure_class, event, status)) + { + summary.count = summary.count.saturating_add(1); + summary.last_sequence = sequence; + summary.last_failed_at_ms = failed_at_ms; + return; + } + + let detailed_capacity = PRODUCT_EVENT_LOSS_SUMMARY_CAPACITY.saturating_sub(1); + if outbox.loss_summaries.len() < detailed_capacity { + outbox.loss_summaries.push(ProductEventLossSummary { + summary_id: Uuid::new_v4().to_string(), + failure_class: failure_class.to_string(), + failed_event_name: event.event_name.clone(), + platform: event.platform.clone(), + app_version: event.app_version.clone(), + anonymous_id: event.anonymous_id.clone(), + session_id: event.session_id.clone(), + status, + count: 1, + first_sequence: sequence, + last_sequence: sequence, + first_failed_at_ms: failed_at_ms, + last_failed_at_ms: failed_at_ms, + }); + return; + } + + if let Some(summary) = outbox + .loss_summaries + .iter_mut() + .find(|summary| is_capacity_loss_summary(summary)) + { + summary.count = summary.count.saturating_add(1); + summary.last_sequence = sequence; + summary.last_failed_at_ms = failed_at_ms; + return; + } + + if outbox.loss_summaries.len() < PRODUCT_EVENT_LOSS_SUMMARY_CAPACITY { + outbox.loss_summaries.push(ProductEventLossSummary { + summary_id: Uuid::new_v4().to_string(), + failure_class: "summary_capacity".to_string(), + failed_event_name: "other".to_string(), + platform: "desktop".to_string(), + app_version: "mixed".to_string(), + anonymous_id: event.anonymous_id.clone(), + session_id: String::new(), + status: None, + count: 1, + first_sequence: sequence, + last_sequence: sequence, + first_failed_at_ms: failed_at_ms, + last_failed_at_ms: failed_at_ms, + }); + } +} + +fn merge_loss_summary(target: &mut Vec, source: ProductEventLossSummary) { + if let Some(existing) = target + .iter_mut() + .find(|summary| summary.summary_id == source.summary_id) + { + if source.last_sequence > existing.last_sequence { + *existing = source; + } + return; + } + if let Some(fallback) = target + .iter_mut() + .find(|summary| is_capacity_loss_summary(summary)) + { + aggregate_loss_summary(fallback, &source); + return; + } + + let detailed_capacity = PRODUCT_EVENT_LOSS_SUMMARY_CAPACITY.saturating_sub(1); + if target.len() < detailed_capacity && !is_capacity_loss_summary(&source) { + target.push(source); + return; + } + + let mut fallback = if target.len() < PRODUCT_EVENT_LOSS_SUMMARY_CAPACITY { + capacity_loss_summary(source) + } else { + let displaced = target.remove(target.len().saturating_sub(1)); + let mut fallback = capacity_loss_summary(displaced); + aggregate_loss_summary(&mut fallback, &source); + fallback + }; + fallback.platform = "desktop".to_string(); + fallback.app_version = "mixed".to_string(); + fallback.session_id.clear(); + target.push(fallback); +} + +fn loss_report_from_summary(summary: ProductEventLossSummary) -> ProductEvent { + let data = event_data(ProductAnalyticsEvent::AnalyticsDeliveryLoss { + failure_class: summary.failure_class, + failed_event_name: summary.failed_event_name, + status: summary.status, + count: summary.count, + first_sequence: summary.first_sequence, + last_sequence: summary.last_sequence, + first_failed_at_ms: summary.first_failed_at_ms, + last_failed_at_ms: summary.last_failed_at_ms, + }); + let occurred_at = + chrono::DateTime::::from_timestamp_millis(summary.last_failed_at_ms) + .unwrap_or_else(chrono::Utc::now) + .to_rfc3339(); + ProductEvent { + event_id: summary.summary_id, + event_name: data.name.to_string(), + occurred_at, + anonymous_id: summary.anonymous_id, + session_id: summary.session_id, + platform: summary.platform, + app_version: summary.app_version, + properties: product_event_properties(&data), + } +} + +fn merge_outbox(target: &mut ProductEventOutbox, source: ProductEventOutbox) { + let mut known_event_ids = target + .pending + .iter() + .map(|event| event.event_id.clone()) + .chain( + target + .dead_letters + .iter() + .map(|entry| entry.event.event_id.clone()), + ) + .chain( + target + .loss_reports_in_flight + .iter() + .map(|event| event.event_id.clone()), + ) + .collect::>(); + for event in source.pending { + if known_event_ids.insert(event.event_id.clone()) { + target.pending.push(event); + } + } + for entry in source.dead_letters { + if known_event_ids.insert(entry.event.event_id.clone()) { + target.dead_letters.push(entry); + } + } + for event in source.loss_reports_in_flight { + target + .loss_summaries + .retain(|summary| summary.summary_id != event.event_id); + if known_event_ids.insert(event.event_id.clone()) { + target.loss_reports_in_flight.push(event); + } + } + for summary in source.loss_summaries { + if !known_event_ids.contains(&summary.summary_id) { + merge_loss_summary(&mut target.loss_summaries, summary); + } + } + target.next_delivery_sequence = target + .next_delivery_sequence + .max(source.next_delivery_sequence) + .max( + target + .loss_summaries + .iter() + .map(|summary| summary.last_sequence) + .max() + .unwrap_or(0), + ); +} + +fn persist_outbox(app: &AppHandle, outbox: &mut ProductEventOutbox) -> Result<(), String> { + if !PRODUCT_EVENT_OUTBOX_RESTORE_BLOCKED.load(Ordering::Acquire) { + if write_outbox(app, PRODUCT_EVENT_OUTBOX_STORE_KEY, outbox).is_ok() { + return truncate_event_journal(app); + } + PRODUCT_EVENT_OUTBOX_RESTORE_BLOCKED.store(true, Ordering::Release); + write_outbox(app, PRODUCT_EVENT_OUTBOX_RECOVERY_STORE_KEY, outbox)?; + return truncate_event_journal(app); + } + + let Ok(mut restored) = load_stored_outbox(app, PRODUCT_EVENT_OUTBOX_STORE_KEY) else { + let (journal, corrupt) = load_event_journal(app)?; + merge_outbox(outbox, journal); + if corrupt { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!("Product analytics journal contained an incomplete record"); + } + write_outbox(app, PRODUCT_EVENT_OUTBOX_RECOVERY_STORE_KEY, outbox)?; + return truncate_event_journal(app); + }; + merge_outbox(&mut restored, outbox.clone()); + let recovery = load_stored_outbox(app, PRODUCT_EVENT_OUTBOX_RECOVERY_STORE_KEY)?; + merge_outbox(&mut restored, recovery); + let (journal, corrupt) = load_event_journal(app)?; + merge_outbox(&mut restored, journal); + if corrupt { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!("Product analytics journal contained an incomplete record"); + } + write_outbox(app, PRODUCT_EVENT_OUTBOX_STORE_KEY, &restored)?; + delete_stored_outbox(app, PRODUCT_EVENT_OUTBOX_RECOVERY_STORE_KEY)?; + delete_legacy_store_outbox_encryption_keys(app)?; + truncate_event_journal(app)?; + *outbox = restored; + PRODUCT_EVENT_OUTBOX_RESTORE_BLOCKED.store(false, Ordering::Release); + Ok(()) +} + +fn outbox_guard() -> std::sync::MutexGuard<'static, ProductEventOutbox> { + PRODUCT_EVENT_OUTBOX + .get_or_init(|| Mutex::new(ProductEventOutbox::default())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn remove_delivered_from_outbox(outbox: &mut ProductEventOutbox, delivered: &[ProductEvent]) { + let delivered_ids = delivered + .iter() + .map(|event| event.event_id.as_str()) + .collect::>(); + outbox + .pending + .retain(|event| !delivered_ids.contains(event.event_id.as_str())); + outbox + .loss_reports_in_flight + .retain(|event| !delivered_ids.contains(event.event_id.as_str())); + outbox + .dead_letters + .retain(|entry| !delivered_ids.contains(entry.event.event_id.as_str())); +} + +fn remove_delivered_events(app: &AppHandle, delivered: &[ProductEvent]) { + let mut outbox = outbox_guard(); + remove_delivered_from_outbox(&mut outbox, delivered); + if let Err(failure_class) = persist_outbox(app, &mut outbox) { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!( + failure_class, + "Failed to persist delivered product analytics state" + ); + } +} + +fn dead_letter_retry_attempts_guard() -> std::sync::MutexGuard<'static, HashSet> { + PRODUCT_EVENT_DEAD_LETTER_RETRY_ATTEMPTS + .get_or_init(|| Mutex::new(HashSet::new())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn select_dead_letter_retry_batch( + outbox: &ProductEventOutbox, + attempted: &mut HashSet, +) -> Vec { + let mut events = Vec::new(); + for entry in &outbox.dead_letters { + if attempted.insert(entry.event.event_id.clone()) { + events.push(entry.event.clone()); + } + if events.len() == PRODUCT_EVENT_BATCH_SIZE { + break; + } + } + events +} + +fn prepare_dead_letter_retry_batch() -> Vec { + let mut attempted = dead_letter_retry_attempts_guard(); + let outbox = outbox_guard(); + select_dead_letter_retry_batch(&outbox, &mut attempted) +} + +fn record_dead_letter_retry_rejection( + app: &AppHandle, + events: &[ProductEvent], + error: &DeliveryError, +) { + let now = chrono::Utc::now().timestamp_millis(); + let mut outbox = outbox_guard(); + record_dead_letter_retry_rejection_in_outbox(&mut outbox, events, error, now); + if let Err(failure_class) = persist_outbox(app, &mut outbox) { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!( + failure_class, + "Failed to persist product analytics dead letter retry state" + ); + } +} + +fn record_dead_letter_retry_rejection_in_outbox( + outbox: &mut ProductEventOutbox, + events: &[ProductEvent], + error: &DeliveryError, + failed_at_ms: i64, +) { + for event in events { + record_delivery_loss( + outbox, + "dead_letter_retry_rejected", + event, + error.status, + failed_at_ms, + ); + } +} + +fn prepare_loss_report_batch(app: &AppHandle) -> Result, String> { + let mut outbox = outbox_guard(); + if outbox.loss_reports_in_flight.is_empty() && !outbox.loss_summaries.is_empty() { + let report_count = outbox.loss_summaries.len().min(PRODUCT_EVENT_BATCH_SIZE); + outbox.loss_reports_in_flight = outbox + .loss_summaries + .drain(..report_count) + .map(loss_report_from_summary) + .collect(); + persist_outbox(app, &mut outbox)?; + } + Ok(outbox.loss_reports_in_flight.clone()) +} + +fn dead_letter_events( + outbox: &mut ProductEventOutbox, + events: &[ProductEvent], + error: &DeliveryError, + failed_at_ms: i64, + failed_at: &str, +) -> (u64, u64) { + let failed_ids = events + .iter() + .map(|event| event.event_id.as_str()) + .collect::>(); + outbox + .pending + .retain(|event| !failed_ids.contains(event.event_id.as_str())); + let mut stored = 0_u64; + let mut dropped = 0_u64; + for event in events { + if outbox.dead_letters.len() < PRODUCT_EVENT_DEAD_LETTER_CAPACITY { + record_delivery_loss( + outbox, + "contract_rejected", + event, + error.status, + failed_at_ms, + ); + outbox.dead_letters.push(ProductEventDeadLetter { + event: event.clone(), + failure_class: "contract_rejected".to_string(), + status: error.status, + failed_at: failed_at.to_string(), + }); + stored = stored.saturating_add(1); + } else { + record_delivery_loss( + outbox, + "dead_letter_overflow", + event, + error.status, + failed_at_ms, + ); + dropped = dropped.saturating_add(1); + } + } + (stored, dropped) +} + +fn move_to_dead_letters(app: &AppHandle, events: &[ProductEvent], error: &DeliveryError) { + let now = chrono::Utc::now(); + let mut outbox = outbox_guard(); + let (stored, dropped) = dead_letter_events( + &mut outbox, + events, + error, + now.timestamp_millis(), + &now.to_rfc3339(), + ); + PRODUCT_EVENT_DEAD_LETTERS.fetch_add(stored, Ordering::Relaxed); + PRODUCT_EVENTS_DROPPED.fetch_add(dropped, Ordering::Relaxed); + if let Err(failure_class) = persist_outbox(app, &mut outbox) { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!( + failure_class, + "Failed to persist product analytics dead letter" + ); + } +} + +async fn run_product_event_worker(app: AppHandle, mut receiver: mpsc::Receiver<()>) { + while receiver.recv().await.is_some() { + let mut retry_attempt = 0_u32; + loop { + if !live_telemetry_enabled(&app) { + break; + } + let (events, batch_kind) = match prepare_loss_report_batch(&app) { + Ok(loss_reports) if !loss_reports.is_empty() => { + (loss_reports, ProductEventBatchKind::LossReport) + } + Ok(_) => { + let outbox = outbox_guard(); + let pending = outbox + .pending + .iter() + .take(PRODUCT_EVENT_BATCH_SIZE) + .cloned() + .collect::>(); + drop(outbox); + if pending.is_empty() { + ( + prepare_dead_letter_retry_batch(), + ProductEventBatchKind::DeadLetterRetry, + ) + } else { + (pending, ProductEventBatchKind::Pending) + } + } + Err(failure_class) => { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!( + failure_class, + "Failed to persist product analytics loss report snapshot" + ); + break; + } + }; + if events.is_empty() { + break; + } + if batch_kind == ProductEventBatchKind::DeadLetterRetry { + PRODUCT_EVENT_RETRIES.fetch_add(events.len() as u64, Ordering::Relaxed); + } + + match send_product_batch_once(&app, &events).await { + Ok(admission) => { + let accepted_events = events + .iter() + .filter(|event| admission.accepted_event_ids.contains(&event.event_id)) + .cloned() + .collect::>(); + let rejected_events = events + .iter() + .filter(|event| admission.rejected_event_ids.contains(&event.event_id)) + .cloned() + .collect::>(); + remove_delivered_events(&app, &accepted_events); + if !rejected_events.is_empty() { + let error = DeliveryError { + retryable: false, + status: Some(409), + }; + match batch_kind { + ProductEventBatchKind::Pending => { + move_to_dead_letters(&app, &rejected_events, &error); + } + ProductEventBatchKind::DeadLetterRetry => { + record_dead_letter_retry_rejection(&app, &rejected_events, &error); + } + ProductEventBatchKind::LossReport => { + warn!( + event_count = rejected_events.len(), + "Product analytics loss reports remain encrypted after selective rejection" + ); + break; + } + } + } + retry_attempt = 0; + } + Err(error) if error.retryable => { + PRODUCT_EVENT_RETRIES.fetch_add(events.len() as u64, Ordering::Relaxed); + let multiplier = 2_u32.saturating_pow(retry_attempt.min(9)); + let delay = PRODUCT_EVENT_RETRY_DELAY + .saturating_mul(multiplier) + .min(PRODUCT_EVENT_MAX_RETRY_DELAY); + retry_attempt = retry_attempt.saturating_add(1); + warn!( + event_count = events.len(), + status = error.status, + retry_attempt, + "Product analytics delivery will retry" + ); + tokio::time::sleep(delay).await; + } + Err(error) => { + match batch_kind { + ProductEventBatchKind::LossReport => { + warn!( + event_count = events.len(), + status = error.status, + "Product analytics loss report was rejected and remains encrypted for recovery" + ); + break; + } + ProductEventBatchKind::DeadLetterRetry => { + record_dead_letter_retry_rejection(&app, &events, &error); + warn!( + event_count = events.len(), + status = error.status, + "Product analytics dead letters remain encrypted after a recovery attempt" + ); + break; + } + ProductEventBatchKind::Pending => {} + } + warn!( + event_count = events.len(), + status = error.status, + "Product analytics delivery entered the encrypted dead letter queue" + ); + move_to_dead_letters(&app, &events, &error); + retry_attempt = 0; + } + } + } + } +} + +fn insert_product_event( + outbox: &mut ProductEventOutbox, + event: ProductEvent, + failed_at_ms: i64, + failed_at: &str, +) -> (bool, bool, bool) { + if outbox.pending.len() < PRODUCT_EVENT_OUTBOX_CAPACITY { + outbox.pending.push(event); + return (true, false, false); + } + + if outbox.dead_letters.len() < PRODUCT_EVENT_DEAD_LETTER_CAPACITY { + record_delivery_loss( + outbox, + "queue_overflow_dead_lettered", + &event, + None, + failed_at_ms, + ); + outbox.dead_letters.push(ProductEventDeadLetter { + event, + failure_class: "queue_overflow".to_string(), + status: None, + failed_at: failed_at.to_string(), + }); + (true, true, false) + } else { + record_delivery_loss( + outbox, + "queue_overflow_unrecoverable", + &event, + None, + failed_at_ms, + ); + (true, false, true) + } +} + +fn enqueue_product_event(app: &AppHandle, event: ProductEvent) -> Result<(), String> { + let now = chrono::Utc::now(); + let journal_event = event.clone(); + let (should_wake, dead_lettered, dropped, persistence) = { + let mut outbox = outbox_guard(); + let (should_wake, dead_lettered, dropped) = insert_product_event( + &mut outbox, + event, + now.timestamp_millis(), + &now.to_rfc3339(), + ); + let persistence = if dead_lettered || dropped { + persist_outbox(app, &mut outbox) + } else { + append_event_journal(app, &journal_event).or_else(|_| persist_outbox(app, &mut outbox)) + }; + (should_wake, dead_lettered, dropped, persistence) + }; + if dead_lettered { + PRODUCT_EVENT_DEAD_LETTERS.fetch_add(1, Ordering::Relaxed); + } + if dropped { + PRODUCT_EVENTS_DROPPED.fetch_add(1, Ordering::Relaxed); + } + if persistence.is_err() { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + } + let sender = product_event_sender(app); + + if should_wake && let Err(mpsc::error::TrySendError::Closed(_)) = sender.try_send(()) { + warn!("Product analytics worker is unavailable; event remains in the encrypted outbox"); + } + persistence +} + +fn product_event_sender(app: &AppHandle) -> &'static mpsc::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 + }) +} + +fn clear_product_event_outbox(outbox: &mut ProductEventOutbox) { + *outbox = ProductEventOutbox::default(); +} + +fn purge_stored_product_analytics(app: &AppHandle) -> Result<(), String> { + clear_product_event_outbox(&mut outbox_guard()); + dead_letter_retry_attempts_guard().clear(); + let store = app + .store("store") + .map_err(|_| "store_unavailable".to_string())?; + store.delete(PRODUCT_EVENT_SESSION_STORE_KEY); + store.delete(PRODUCT_EVENT_OUTBOX_STORE_KEY); + store.delete(PRODUCT_EVENT_OUTBOX_RECOVERY_STORE_KEY); + store + .save() + .map_err(|_| "analytics_opt_out_purge_failed".to_string())?; + delete_event_journal(app) +} + +pub fn init_product_session(app: &AppHandle) { + if !telemetry_enabled() { + if let Err(failure_class) = purge_stored_product_analytics(app) { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!( + failure_class, + "Failed to purge opted-out product analytics state" + ); + } + return; + } + + 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}"), + } + + let journal = load_event_journal(app); + match load_stored_outbox(app, PRODUCT_EVENT_OUTBOX_STORE_KEY) { + Ok(mut loaded) => { + if let Ok((journal_outbox, corrupt)) = &journal { + merge_outbox(&mut loaded, journal_outbox.clone()); + if *corrupt { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!("Product analytics journal contained an incomplete record"); + } + } + match load_stored_outbox(app, PRODUCT_EVENT_OUTBOX_RECOVERY_STORE_KEY) { + Ok(recovery) => { + merge_outbox(&mut loaded, recovery); + let consolidation = write_outbox(app, PRODUCT_EVENT_OUTBOX_STORE_KEY, &loaded) + .and_then(|()| { + delete_stored_outbox(app, PRODUCT_EVENT_OUTBOX_RECOVERY_STORE_KEY) + }) + .and_then(|()| delete_legacy_store_outbox_encryption_keys(app)) + .and_then(|()| match journal { + Ok(_) => truncate_event_journal(app), + Err(ref failure_class) => Err(failure_class.clone()), + }); + if let Err(failure_class) = consolidation { + PRODUCT_EVENT_OUTBOX_RESTORE_BLOCKED.store(true, Ordering::Release); + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!( + failure_class, + "Failed to consolidate product analytics recovery outbox" + ); + } else { + PRODUCT_EVENT_OUTBOX_RESTORE_BLOCKED.store(false, Ordering::Release); + } + } + Err(failure_class) => { + PRODUCT_EVENT_OUTBOX_RESTORE_BLOCKED.store(true, Ordering::Release); + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!( + failure_class, + "Failed to restore product analytics recovery outbox" + ); + } + } + let has_pending = !loaded.pending.is_empty() + || !loaded.dead_letters.is_empty() + || !loaded.loss_summaries.is_empty() + || !loaded.loss_reports_in_flight.is_empty(); + *outbox_guard() = loaded; + if has_pending { + let _ = product_event_sender(app).try_send(()); + } + } + Err(failure_class) => { + PRODUCT_EVENT_OUTBOX_RESTORE_BLOCKED.store(true, Ordering::Release); + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + match load_stored_outbox(app, PRODUCT_EVENT_OUTBOX_RECOVERY_STORE_KEY) { + Ok(mut recovery) => { + if let Ok((journal_outbox, corrupt)) = &journal { + merge_outbox(&mut recovery, journal_outbox.clone()); + if *corrupt { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!("Product analytics journal contained an incomplete record"); + } + } + *outbox_guard() = recovery; + } + Err(recovery_failure_class) => { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!( + failure_class = recovery_failure_class, + "Failed to restore product analytics recovery outbox" + ); + } + } + warn!( + failure_class, + "Failed to restore encrypted product analytics outbox" + ); + let has_pending = { + let outbox = outbox_guard(); + !outbox.pending.is_empty() + || !outbox.dead_letters.is_empty() + || !outbox.loss_summaries.is_empty() + || !outbox.loss_reports_in_flight.is_empty() + }; + if has_pending { + let _ = product_event_sender(app).try_send(()); + } + } + } +} + +static TELEMETRY_ENABLED: AtomicBool = AtomicBool::new(true); +static PRODUCT_EVENT_SESSION_ID: OnceLock = OnceLock::new(); +static PRODUCT_EVENT_SENDER: OnceLock> = OnceLock::new(); +static PRODUCT_EVENT_OUTBOX: OnceLock> = OnceLock::new(); +static PRODUCT_EVENT_DEAD_LETTER_RETRY_ATTEMPTS: OnceLock>> = OnceLock::new(); +static PRODUCT_EVENT_OUTBOX_ENCRYPTION_KEY: OnceLock<[u8; 32]> = OnceLock::new(); +static PRODUCT_EVENT_OUTBOX_RESTORE_BLOCKED: AtomicBool = AtomicBool::new(false); +static PRODUCT_EVENTS_DROPPED: AtomicU64 = AtomicU64::new(0); +static PRODUCT_EVENT_RETRIES: AtomicU64 = AtomicU64::new(0); +static PRODUCT_EVENT_DEAD_LETTERS: AtomicU64 = AtomicU64::new(0); +static PRODUCT_EVENT_PERSISTENCE_FAILURES: AtomicU64 = AtomicU64::new(0); + +pub fn set_telemetry_enabled(app: &AppHandle, enabled: bool) { + TELEMETRY_ENABLED.store(enabled, Ordering::Release); + if !enabled && let Err(failure_class) = purge_stored_product_analytics(app) { + PRODUCT_EVENT_PERSISTENCE_FAILURES.fetch_add(1, Ordering::Relaxed); + warn!( + failure_class, + "Failed to purge opted-out product analytics state" + ); + } +} + +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 data = event_data(event); + + if let Some(event) = product_event(&data, anonymous_id) { + if let Err(failure_class) = enqueue_product_event(app, event) { + warn!( + failure_class, + "Critical product analytics event is retained only in process memory" + ); + } + } +} + +#[tauri::command] +#[specta::specta] +pub fn capture_client_product_analytics_event( + app: AppHandle, + event_id: String, + event_name: String, + occurred_at: String, + properties: String, +) -> Result<(), String> { + if !live_telemetry_enabled(&app) { + return Ok(()); + } + let event_name = desktop_client_product_event_name(&event_name) + .ok_or_else(|| "unregistered_product_event".to_string())?; + Uuid::parse_str(&event_id).map_err(|_| "invalid_product_event_id".to_string())?; + chrono::DateTime::parse_from_rfc3339(&occurred_at) + .map_err(|_| "invalid_product_event_timestamp".to_string())?; + let properties = serde_json::from_str::>(&properties) + .map_err(|_| "invalid_product_event_properties".to_string())?; + let data = EventData { + name: event_name, + properties, + }; + 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() + }); + enqueue_product_event( + &app, + ProductEvent { + event_id, + event_name: event_name.to_string(), + occurred_at, + anonymous_id, + session_id: PRODUCT_EVENT_SESSION_ID + .get_or_init(Uuid::new_v4) + .to_string(), + platform: "desktop".to_string(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + properties: product_event_properties(&data), + }, + ) +} + +#[cfg(test)] +mod tests { + 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, + } + } + + fn product_event_with_id(event_id: &str) -> ProductEvent { + let mut event = product_event(&event_data(recording_started()), "install-id".to_string()) + .expect("recording event should be registered"); + event.event_id = event_id.to_string(); + event + } + + #[test] + fn core_product_event_catalog_is_intentionally_small() { + let included = [ + "recording_started", + "recording_start_failed", + "recording_completed", + "multipart_upload_complete", + "multipart_upload_failed", + "recording_recovery_failed", + "analytics_delivery_loss", + ]; + 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 recording_start_failure_is_bounded_and_excludes_raw_errors() { + let data = event_data(ProductAnalyticsEvent::RecordingStartFailed { + mode: "instant", + error: "/Users/private/recording.cap permission denied".to_string(), + }); + let event = product_event(&data, "install-id".to_string()).unwrap(); + + assert_eq!(event.event_name, "recording_start_failed"); + assert_eq!(event.properties["mode"], "instant"); + assert_eq!(event.properties["failure_class"], "permission"); + assert!(!event.properties.contains_key("error")); + } + + #[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); + assert_eq!(event.properties["failure_class"], "unknown"); + } + + #[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()); + } + + #[test] + fn encrypted_outbox_round_trips_without_plaintext_event_data() { + let data = event_data(recording_started()); + let event = product_event(&data, "install-id".to_string()).unwrap(); + let event_id = event.event_id.clone(); + let outbox = ProductEventOutbox { + pending: vec![event], + ..ProductEventOutbox::default() + }; + let key = [7_u8; 32]; + + let encrypted = encrypt_outbox_with_key(&outbox, &key).unwrap(); + assert!(!encrypted.contains("recording_started")); + assert!(!encrypted.contains(&event_id)); + + let decrypted = decrypt_outbox_with_key(&encrypted, &key).unwrap(); + assert_eq!(decrypted.pending.len(), 1); + assert_eq!(decrypted.pending[0].event_id, event_id); + } + + #[test] + fn encrypted_durable_journal_enqueue_meets_latency_and_size_budgets() { + let data = event_data(recording_started()); + let event = product_event(&data, "install-id".to_string()).unwrap(); + let mut pending = Vec::with_capacity(PRODUCT_EVENT_OUTBOX_CAPACITY); + for index in 0..PRODUCT_EVENT_OUTBOX_CAPACITY { + let mut queued = event.clone(); + queued.event_id = format!("event-{index}"); + pending.push(queued); + } + let full_snapshot = encrypt_outbox_with_key( + &ProductEventOutbox { + pending, + ..ProductEventOutbox::default() + }, + &[7_u8; 32], + ) + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("journal"); + let encryption_key = [7_u8; 32]; + let warmup_sample_count = 10; + let measured_sample_count = 100; + let mut failures = 0_usize; + let mut appended_bytes = 0_usize; + for index in 0..warmup_sample_count { + let mut warmup_event = event.clone(); + warmup_event.event_id = format!("warmup-{index}"); + match append_encrypted_event_journal(&path, &warmup_event, &encryption_key) { + Ok(bytes) => appended_bytes = appended_bytes.saturating_add(bytes), + Err(_) => failures = failures.saturating_add(1), + } + } + + let mut timings = Vec::with_capacity(measured_sample_count); + let mut record_sizes = Vec::with_capacity(measured_sample_count); + let measured_started = std::time::Instant::now(); + for index in 0..measured_sample_count { + let mut measured_event = event.clone(); + measured_event.event_id = format!("measured-{index}"); + let started = std::time::Instant::now(); + match append_encrypted_event_journal(&path, &measured_event, &encryption_key) { + Ok(bytes) => { + timings.push(started.elapsed()); + record_sizes.push(bytes); + appended_bytes = appended_bytes.saturating_add(bytes); + } + Err(_) => failures = failures.saturating_add(1), + } + } + let measured_elapsed = measured_started.elapsed(); + timings.sort_unstable(); + let p50 = timings.get(49).copied().unwrap_or(Duration::MAX); + let p95 = timings.get(94).copied().unwrap_or(Duration::MAX); + let p99 = timings.get(98).copied().unwrap_or(Duration::MAX); + let throughput = timings.len() as f64 / measured_elapsed.as_secs_f64(); + let journal_size = usize::try_from(fs::metadata(&path).unwrap().len()).unwrap(); + let max_record_size = record_sizes.iter().copied().max().unwrap_or(usize::MAX); + + eprintln!( + "CAP_ANALYTICS_DESKTOP_PERF={{\"samples\":{},\"failures\":{},\"p50_ms\":{:.3},\"p95_ms\":{:.3},\"p99_ms\":{:.3},\"throughput_events_per_second\":{:.2},\"journal_bytes\":{},\"max_record_bytes\":{},\"full_snapshot_bytes\":{}}}", + timings.len(), + failures, + p50.as_secs_f64() * 1_000.0, + p95.as_secs_f64() * 1_000.0, + p99.as_secs_f64() * 1_000.0, + throughput, + journal_size, + max_record_size, + full_snapshot.len() + ); + + assert_eq!(failures, 0); + assert_eq!(timings.len(), measured_sample_count); + assert_eq!(journal_size, appended_bytes); + assert!(journal_size < full_snapshot.len()); + assert!(max_record_size.saturating_mul(10) < full_snapshot.len()); + assert!(p50 < Duration::from_millis(25)); + assert!(p95 < Duration::from_millis(50)); + assert!(p99 < Duration::from_millis(100)); + assert!(throughput >= 20.0); + } + + #[test] + fn encrypted_outbox_tries_every_persisted_key_without_replacing_it() { + let data = event_data(recording_started()); + let event = product_event(&data, "install-id".to_string()).unwrap(); + let event_id = event.event_id.clone(); + let outbox = ProductEventOutbox { + pending: vec![event], + ..ProductEventOutbox::default() + }; + let valid_key = [7_u8; 32]; + let encrypted = encrypt_outbox_with_key(&outbox, &valid_key).unwrap(); + + let (decrypted, selected_key) = + decrypt_outbox_with_keys(&encrypted, &[[8_u8; 32], valid_key]).unwrap(); + + assert_eq!(selected_key, valid_key); + assert_eq!(decrypted.pending[0].event_id, event_id); + } + + #[test] + fn keyring_outbox_key_requires_a_valid_aes_key() { + let key = [7_u8; 32]; + + assert_eq!( + decode_outbox_encryption_key(&BASE64.encode(key)).unwrap(), + key + ); + assert!(decode_outbox_encryption_key("invalid").is_err()); + } + + #[test] + fn legacy_outbox_keys_remain_readable_during_migration() { + let first = [7_u8; 32]; + let second = [8_u8; 32]; + + assert_eq!( + decode_legacy_outbox_encryption_keys(&Value::String(BASE64.encode(first))).unwrap(), + vec![first] + ); + assert_eq!( + decode_legacy_outbox_encryption_keys(&serde_json::json!([ + BASE64.encode(first), + BASE64.encode(second) + ])) + .unwrap(), + vec![first, second] + ); + } + + #[test] + fn fallback_key_file_is_created_once_and_concurrent_initializers_adopt_it() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("outbox-key"); + let first = [7_u8; 32]; + + assert_eq!(initialize_file_outbox_key(&path, first).unwrap(), first); + + assert_eq!(read_file_outbox_key(&path).unwrap(), Some(first)); + assert_eq!( + initialize_file_outbox_key(&path, [8_u8; 32]).unwrap(), + first + ); + #[cfg(unix)] + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn recovery_outbox_merge_preserves_unique_events() { + let first = + product_event(&event_data(recording_started()), "install-id".to_string()).unwrap(); + let mut duplicate = first.clone(); + duplicate.event_name = "recording_completed".to_string(); + let second = product_event( + &event_data(recording_started()), + "second-install-id".to_string(), + ) + .unwrap(); + let mut target = ProductEventOutbox { + pending: vec![first.clone()], + ..ProductEventOutbox::default() + }; + + merge_outbox( + &mut target, + ProductEventOutbox { + pending: vec![duplicate, second.clone()], + ..ProductEventOutbox::default() + }, + ); + + assert_eq!(target.pending.len(), 2); + assert_eq!(target.pending[0].event_name, first.event_name); + assert_eq!(target.pending[1].event_id, second.event_id); + } + + #[test] + fn dead_letter_capacity_never_evicts_recoverable_events() { + let mut outbox = ProductEventOutbox::default(); + for index in 0..PRODUCT_EVENT_DEAD_LETTER_CAPACITY { + let mut event = + product_event(&event_data(recording_started()), "install-id".to_string()).unwrap(); + event.event_id = format!("dead-letter-{index}"); + outbox.dead_letters.push(ProductEventDeadLetter { + event, + failure_class: "contract_rejected".to_string(), + status: Some(400), + failed_at: "2026-08-01T00:00:00Z".to_string(), + }); + } + let oldest_id = outbox.dead_letters[0].event.event_id.clone(); + let event = product_event( + &event_data(recording_started()), + "overflow-install-id".to_string(), + ) + .unwrap(); + let event_id = event.event_id.clone(); + outbox.pending = (0..PRODUCT_EVENT_OUTBOX_CAPACITY) + .map(|index| { + let mut pending = event.clone(); + pending.event_id = format!("pending-{index}"); + pending + }) + .collect(); + + let (wake, dead_lettered, dropped) = insert_product_event( + &mut outbox, + event, + 1_785_542_400_000, + "2026-08-01T00:00:00Z", + ); + + assert!(wake); + assert!(!dead_lettered); + assert!(dropped); + assert_eq!( + outbox.dead_letters.len(), + PRODUCT_EVENT_DEAD_LETTER_CAPACITY + ); + assert_eq!(outbox.dead_letters[0].event.event_id, oldest_id); + assert!( + !outbox + .dead_letters + .iter() + .any(|entry| entry.event.event_id == event_id) + ); + assert_eq!(outbox.loss_summaries.len(), 1); + assert_eq!( + outbox.loss_summaries[0].failure_class, + "queue_overflow_unrecoverable" + ); + } + + #[test] + fn dead_letters_retry_once_per_process_and_again_after_restart() { + let mut outbox = ProductEventOutbox::default(); + for index in 0..=PRODUCT_EVENT_BATCH_SIZE { + let mut event = + product_event(&event_data(recording_started()), "install-id".to_string()).unwrap(); + event.event_id = format!("dead-letter-{index}"); + outbox.dead_letters.push(ProductEventDeadLetter { + event, + failure_class: "contract_rejected".to_string(), + status: Some(400), + failed_at: "2026-08-01T00:00:00Z".to_string(), + }); + } + let mut attempted = HashSet::new(); + + let first = select_dead_letter_retry_batch(&outbox, &mut attempted); + let second = select_dead_letter_retry_batch(&outbox, &mut attempted); + let exhausted = select_dead_letter_retry_batch(&outbox, &mut attempted); + let restarted = select_dead_letter_retry_batch(&outbox, &mut HashSet::new()); + + assert_eq!(first.len(), PRODUCT_EVENT_BATCH_SIZE); + assert_eq!(second.len(), 1); + assert!(exhausted.is_empty()); + assert_eq!( + restarted + .iter() + .map(|event| event.event_id.as_str()) + .collect::>(), + first + .iter() + .map(|event| event.event_id.as_str()) + .collect::>() + ); + assert_eq!(outbox.dead_letters.len(), PRODUCT_EVENT_BATCH_SIZE + 1); + } + + #[test] + fn dead_letter_recovery_retains_rejections_and_removes_accepted_events() { + let event = + product_event(&event_data(recording_started()), "install-id".to_string()).unwrap(); + let mut outbox = ProductEventOutbox { + dead_letters: vec![ProductEventDeadLetter { + event: event.clone(), + failure_class: "contract_rejected".to_string(), + status: Some(400), + failed_at: "2026-08-01T00:00:00Z".to_string(), + }], + ..ProductEventOutbox::default() + }; + let error = DeliveryError { + retryable: false, + status: Some(422), + }; + + record_dead_letter_retry_rejection_in_outbox( + &mut outbox, + std::slice::from_ref(&event), + &error, + 1_785_542_400_000, + ); + + assert_eq!(outbox.dead_letters.len(), 1); + assert_eq!(outbox.loss_summaries.len(), 1); + assert_eq!( + outbox.loss_summaries[0].failure_class, + "dead_letter_retry_rejected" + ); + assert_eq!(outbox.loss_summaries[0].status, Some(422)); + + remove_delivered_from_outbox(&mut outbox, &[event]); + + assert!(outbox.dead_letters.is_empty()); + } + + #[test] + fn loss_report_payload_and_id_survive_encrypted_restart() { + let event = + product_event(&event_data(recording_started()), "install-id".to_string()).unwrap(); + let mut outbox = ProductEventOutbox::default(); + record_delivery_loss( + &mut outbox, + "queue_overflow_unrecoverable", + &event, + None, + 1_785_542_400_000, + ); + let report = loss_report_from_summary(outbox.loss_summaries.remove(0)); + let report_id = report.event_id.clone(); + let report_json = serde_json::to_value(&report).unwrap(); + outbox.loss_reports_in_flight.push(report); + let key = [9_u8; 32]; + + let encrypted = encrypt_outbox_with_key(&outbox, &key).unwrap(); + let restored = decrypt_outbox_with_key(&encrypted, &key).unwrap(); + + assert!(!encrypted.contains("queue_overflow_unrecoverable")); + assert_eq!(restored.loss_reports_in_flight.len(), 1); + assert_eq!(restored.loss_reports_in_flight[0].event_id, report_id); + assert_eq!( + serde_json::to_value(&restored.loss_reports_in_flight[0]).unwrap(), + report_json + ); + } + + #[test] + fn loss_summary_capacity_preserves_counts_across_app_versions() { + let mut outbox = ProductEventOutbox::default(); + for index in 0..=PRODUCT_EVENT_LOSS_SUMMARY_CAPACITY { + let mut event = + product_event(&event_data(recording_started()), "install-id".to_string()).unwrap(); + event.app_version = format!("version-{index}"); + record_delivery_loss( + &mut outbox, + "queue_overflow_unrecoverable", + &event, + None, + 1_785_542_400_000_i64 + .saturating_add(i64::try_from(index).expect("capacity index fits i64")), + ); + } + + assert_eq!( + outbox.loss_summaries.len(), + PRODUCT_EVENT_LOSS_SUMMARY_CAPACITY + ); + assert_eq!( + outbox + .loss_summaries + .iter() + .map(|summary| summary.count) + .sum::(), + u64::try_from(PRODUCT_EVENT_LOSS_SUMMARY_CAPACITY) + .expect("loss summary capacity fits u64") + + 1 + ); + let fallback = outbox + .loss_summaries + .iter() + .find(|summary| is_capacity_loss_summary(summary)) + .unwrap(); + assert_eq!(fallback.count, 2); + assert_eq!(fallback.app_version, "mixed"); + } + + #[test] + fn recovery_merge_is_idempotent_for_loss_summaries() { + let event = + product_event(&event_data(recording_started()), "install-id".to_string()).unwrap(); + let mut source = ProductEventOutbox::default(); + record_delivery_loss( + &mut source, + "queue_overflow_dead_lettered", + &event, + None, + 1_785_542_400_000, + ); + let mut target = ProductEventOutbox::default(); + + merge_outbox(&mut target, source.clone()); + merge_outbox(&mut target, source); + + assert_eq!(target.loss_summaries.len(), 1); + assert_eq!(target.loss_summaries[0].count, 1); + assert_eq!(target.next_delivery_sequence, 1); + } + + #[test] + fn old_encrypted_outbox_shape_defaults_new_delivery_state() { + let json = serde_json::json!({ "pending": [], "dead_letters": [] }); + let outbox = serde_json::from_value::(json).unwrap(); + + assert!(outbox.loss_summaries.is_empty()); + assert!(outbox.loss_reports_in_flight.is_empty()); + assert_eq!(outbox.next_delivery_sequence, 0); + } + + #[test] + fn opt_out_clear_removes_all_recoverable_delivery_state() { + let event = + product_event(&event_data(recording_started()), "install-id".to_string()).unwrap(); + let mut outbox = ProductEventOutbox { + pending: vec![event.clone()], + dead_letters: vec![ProductEventDeadLetter { + event: event.clone(), + failure_class: "contract_rejected".to_string(), + status: Some(400), + failed_at: "2026-08-01T00:00:00Z".to_string(), + }], + ..ProductEventOutbox::default() + }; + record_delivery_loss( + &mut outbox, + "queue_overflow_dead_lettered", + &event, + None, + 1_785_542_400_000, + ); + outbox + .loss_reports_in_flight + .push(loss_report_from_summary(outbox.loss_summaries.remove(0))); + + clear_product_event_outbox(&mut outbox); + + assert!(outbox.pending.is_empty()); + assert!(outbox.dead_letters.is_empty()); + assert!(outbox.loss_summaries.is_empty()); + assert!(outbox.loss_reports_in_flight.is_empty()); + assert_eq!(outbox.next_delivery_sequence, 0); + } + + #[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(404)); + assert!(should_retry_product_status(410)); + assert!(should_retry_product_status(429)); + assert!(should_retry_product_status(503)); + } + + #[test] + fn product_admission_is_selective_and_fail_closed() { + let first = product_event_with_id("first"); + let second = product_event_with_id("second"); + let events = [first, second]; + let admission = parse_product_event_admission( + &events, + ProductEventAdmissionResponse { + accepted: 1, + accepted_event_ids: Some(vec!["first".to_string()]), + rejected_event_ids: Some(vec!["second".to_string()]), + }, + ) + .expect("selective admission should be valid"); + assert_eq!( + admission.accepted_event_ids, + HashSet::from(["first".to_string()]) + ); + assert_eq!( + admission.rejected_event_ids, + HashSet::from(["second".to_string()]) + ); + + assert!( + parse_product_event_admission( + &events, + ProductEventAdmissionResponse { + accepted: 1, + accepted_event_ids: Some(vec!["first".to_string()]), + rejected_event_ids: Some(Vec::new()), + }, + ) + .is_none() + ); + } +} diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index b3c2c35d02f..6b9ac7b2bbb 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1146,8 +1146,22 @@ pub enum RecordingEvent { /// event the main window surfaces to the user. The picker overlay that invoked the /// command is often already closed (or being torn down) when the error comes back, so /// an error returned to the caller alone can vanish without a trace. -fn notify_recording_start_failed(app: &AppHandle, error: &str) { +fn capture_recording_start_failed(app: &AppHandle, mode: RecordingMode, error: &str) { + use crate::product_analytics::{ProductAnalyticsEvent, capture_event}; + use crate::recording_telemetry::mode_label; + + capture_event( + app, + ProductAnalyticsEvent::RecordingStartFailed { + mode: mode_label(mode), + error: error.to_string(), + }, + ); +} + +fn notify_recording_start_failed(app: &AppHandle, mode: RecordingMode, error: &str) { error!(%error, "Recording failed to start"); + capture_recording_start_failed(app, mode, error); let _ = RecordingEvent::StartFailed { error: error.to_string(), } @@ -1507,7 +1521,7 @@ pub async fn start_recording( drop(app_state); // Deliberately no clear_pending_recording: the pending/active state that // caused the refusal belongs to another recording and must survive. - notify_recording_start_failed(&app, &error); + notify_recording_start_failed(&app, inputs.mode, &error); return Err(error); } if is_camera_only { @@ -1522,7 +1536,7 @@ pub async fn start_recording( Err(err) => { let error = ($map_err)(err); state_mtx.write().await.clear_pending_recording(); - notify_recording_start_failed(&app, &error); + notify_recording_start_failed(&app, inputs.mode, &error); return Err(error); } } @@ -1535,7 +1549,7 @@ pub async fn start_recording( if let Err(err) = (ShowCapWindow::Camera { centered: true }).show(&app).await { let error = format!("Failed to show centered camera window: {err}"); state_mtx.write().await.clear_pending_recording(); - notify_recording_start_failed(&app, &error); + notify_recording_start_failed(&app, inputs.mode, &error); return Err(error); } } @@ -1580,7 +1594,7 @@ pub async fn start_recording( "Refusing to start recording: disk full" ); state_mtx.write().await.clear_pending_recording(); - notify_recording_start_failed(&app, &error); + notify_recording_start_failed(&app, inputs.mode, &error); return Err(error); } if bytes <= cap_utils::disk_space::LOW_DISK_WARN_BYTES { @@ -1628,7 +1642,7 @@ pub async fn start_recording( let Some(auth) = AuthStore::get(&app).ok().flatten() else { let error = "Please sign in to use instant recording".to_string(); state_mtx.write().await.clear_pending_recording(); - notify_recording_start_failed(&app, &error); + notify_recording_start_failed(&app, inputs.mode, &error); return Err(error); }; let instant_mode_max_resolution = if auth.is_upgraded() { @@ -1663,6 +1677,7 @@ pub async fn start_recording( // invoked us may already be gone — surface it as a start failure too. notify_recording_start_failed( &app, + inputs.mode, "Your session has expired. Please sign in again to use instant recording.", ); return Ok(RecordingAction::InvalidAuthentication); @@ -1671,6 +1686,7 @@ pub async fn start_recording( state_mtx.write().await.clear_pending_recording(); notify_recording_start_failed( &app, + inputs.mode, "Instant recording requires an upgraded plan.", ); return Ok(RecordingAction::UpgradeRequired); @@ -1678,7 +1694,7 @@ pub async fn start_recording( Err(err) => { let error = format!("Could not create the shareable link: {err}"); state_mtx.write().await.clear_pending_recording(); - notify_recording_start_failed(&app, &error); + notify_recording_start_failed(&app, inputs.mode, &error); return Err(error); } }; @@ -1699,7 +1715,7 @@ pub async fn start_recording( RecordingMode::Screenshot => { let error = "Use take_screenshot for screenshots".to_string(); state_mtx.write().await.clear_pending_recording(); - notify_recording_start_failed(&app, &error); + notify_recording_start_failed(&app, inputs.mode, &error); return Err(error); } }; @@ -2085,6 +2101,7 @@ pub async fn start_recording( Ok(Ok(v)) => v, Ok(Err(err)) => { let message = format!("{err:#}"); + capture_recording_start_failed(&app, inputs.mode, &message); handle_spawn_failure( &app, &state_mtx, @@ -2097,6 +2114,7 @@ pub async fn start_recording( Err(panic) => { let panic_msg = panic_message(panic); let message = format!("Failed to spawn recording actor: {panic_msg}"); + capture_recording_start_failed(&app, inputs.mode, &message); handle_spawn_failure( &app, &state_mtx, @@ -2183,110 +2201,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 { @@ -3039,9 +2955,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, @@ -3981,13 +3897,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(_) => { @@ -3998,58 +3912,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(), }, @@ -4089,7 +3959,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) = { @@ -4126,9 +3996,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/routes/(window-chrome)/new-main/CameraSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx index 0f3a09e95b8..0041157e059 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/CameraSelect.tsx @@ -250,7 +250,6 @@ export function CameraSelectBase(props: { props.onChange(cameraLabel); trackEvent("camera_selected", { - camera_name: cameraLabel?.display_name ?? null, enabled: !!cameraLabel, }); }; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx index 4084064b06a..e0d8553c59f 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/MicrophoneSelect.tsx @@ -49,7 +49,6 @@ export default function MicrophoneSelect(props: { if (!name) setDbs(); trackEvent("microphone_selected", { - microphone_name: name ?? null, enabled: !!name, }); }; @@ -167,7 +166,6 @@ export function MicrophoneSelectBase(props: { if (!item) setDbs(); trackEvent("microphone_selected", { - microphone_name: item?.name ?? null, enabled: !!item, }); }; diff --git a/apps/desktop/src/routes/(window-chrome)/settings.tsx b/apps/desktop/src/routes/(window-chrome)/settings.tsx index 3a7a93609b6..bd1b7ae4cfd 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings.tsx @@ -403,7 +403,7 @@ export default function Settings(props: RouteSectionProps) { const handleAuth = async () => { if (auth()) { - trackEvent("user_signed_out", { platform: "desktop" }); + trackEvent("user_signed_out"); await clearLocalAuth(); } }; diff --git a/apps/desktop/src/routes/editor/ExportPage.tsx b/apps/desktop/src/routes/editor/ExportPage.tsx index 370a891c062..2bd0813f58d 100644 --- a/apps/desktop/src/routes/editor/ExportPage.tsx +++ b/apps/desktop/src/routes/editor/ExportPage.tsx @@ -519,6 +519,28 @@ export function ExportPage() { : settings.format === "Gif" ? "GIF" : "video"; + const exportAnalyticsProperties = ( + destination: "clipboard" | "file" | "share_link", + ) => ({ + destination, + format: exportFileExtension(), + resolution: settings.resolution.value, + fps: settings.fps, + }); + const exportFailureClass = (error: unknown) => { + if (isCancelled() || isCancellationError(error)) + return "cancelled" as const; + const message = error instanceof Error ? error.message.toLowerCase() : ""; + if (message.includes("sign in") || message.includes("auth")) + return "authentication" as const; + if (message.includes("plan") || message.includes("upgrade")) + return "plan" as const; + if (message.includes("network") || message.includes("upload")) + return "network" as const; + if (message.includes("render") || message.includes("export")) + return "render" as const; + return "unknown" as const; + }; const handleCancel = async () => { if ( @@ -548,6 +570,7 @@ export function ExportPage() { if (exportState.type !== "idle") return; const releaseExportSession = await beginExportSessionGuard(); try { + trackEvent("export_started", exportAnalyticsProperties("clipboard")); setExportState(reconcile({ action: "copy", type: "starting" })); const outputPath = await exportWithSettings((progress) => { @@ -565,6 +588,10 @@ export function ExportPage() { } }, onError: (error) => { + trackEvent("export_failed", { + ...exportAnalyticsProperties("clipboard"), + failure_class: exportFailureClass(error), + }); if (isCancelled() || isCancellationError(error)) { setExportState(reconcile({ type: "idle" })); return; @@ -575,6 +602,7 @@ export function ExportPage() { setExportState(reconcile({ type: "idle" })); }, onSuccess() { + trackEvent("export_completed", exportAnalyticsProperties("clipboard")); setExportState({ type: "done" }); toast.success(`${exportedAssetLabel()} exported to clipboard`); }, @@ -584,6 +612,7 @@ export function ExportPage() { mutationFn: async () => { setIsCancelled(false); if (exportState.type !== "idle") return; + trackEvent("export_started", exportAnalyticsProperties("file")); const extension = exportFileExtension(); const customBpp = advancedMode() && isCustomBpp() ? compressionBpp() : null; @@ -620,6 +649,10 @@ export function ExportPage() { setExportState({ type: "done" }); }, onError: (error) => { + trackEvent("export_failed", { + ...exportAnalyticsProperties("file"), + failure_class: exportFailureClass(error), + }); if (isCancelled() || isCancellationError(error)) { setExportState({ type: "idle" }); return; @@ -632,6 +665,7 @@ export function ExportPage() { setExportState({ type: "idle" }); }, onSuccess() { + trackEvent("export_completed", exportAnalyticsProperties("file")); toast.success(`${exportedAssetLabel()} exported to file`); }, })); @@ -642,12 +676,13 @@ export function ExportPage() { if (exportState.type !== "idle") return; const releaseExportSession = await beginExportSessionGuard(); try { + trackEvent("export_started", exportAnalyticsProperties("share_link")); setExportState(reconcile({ action: "upload", type: "starting" })); const existingAuth = await authStore.get(); if (!existingAuth) createSignInMutation(); trackEvent("create_shareable_link_clicked", { - resolution: settings.resolution, + resolution: settings.resolution.value, fps: settings.fps, has_existing_auth: !!existingAuth, }); @@ -714,10 +749,15 @@ export function ExportPage() { } }, onSuccess: async () => { + trackEvent("export_completed", exportAnalyticsProperties("share_link")); await refetchMeta(); setExportState({ type: "done" }); }, onError: (error) => { + trackEvent("export_failed", { + ...exportAnalyticsProperties("share_link"), + failure_class: exportFailureClass(error), + }); if (isCancelled() || isCancellationError(error)) { setExportState(reconcile({ type: "idle" })); return; diff --git a/apps/desktop/src/utils/analytics.test.ts b/apps/desktop/src/utils/analytics.test.ts new file mode 100644 index 00000000000..31fb984fc6d --- /dev/null +++ b/apps/desktop/src/utils/analytics.test.ts @@ -0,0 +1,176 @@ +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, + invoke: vi.fn( + async ( + _command: string, + _input?: { + eventId?: string; + eventName?: string; + occurredAt?: string; + properties?: string; + }, + ) => undefined, + ), + 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/api/core", () => ({ invoke: mocks.invoke })); +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.invoke.mockReset(); + mocks.invoke.mockResolvedValue(undefined); + mocks.fetch.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("persists a normalized core event through the native outbox", async () => { + const { trackEvent } = await loadAnalytics(); + trackEvent("create_shareable_link_clicked", { + resolution: "1080p", + fps: 60, + has_existing_auth: true, + }); + await flushMicrotasks(); + + expect(mocks.invoke).toHaveBeenCalledOnce(); + const [command, input] = mocks.invoke.mock.calls[0] ?? []; + expect(command).toBe("capture_client_product_analytics_event"); + expect(input).toMatchObject({ + eventName: "create_shareable_link_clicked", + properties: JSON.stringify({ + resolution: "1080p", + fps: 60, + has_existing_auth: true, + }), + }); + expect(input?.eventId).toMatch(/^[0-9a-f-]{36}$/); + expect(input?.occurredAt).toEqual(expect.any(String)); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("sends registered finite desktop interactions", async () => { + const { trackEvent } = await loadAnalytics(); + trackEvent("camera_selected", { enabled: true }); + await flushMicrotasks(); + await vi.advanceTimersByTimeAsync(250); + + expect(mocks.invoke).toHaveBeenCalledOnce(); + }); + + it("never accepts a client-authored revenue event", async () => { + const { trackEvent } = await loadAnalytics(); + const unsafeTrackEvent = trackEvent as unknown as ( + eventName: string, + properties?: Record, + ) => void; + unsafeTrackEvent("purchase_completed", { quantity: 10 }); + await flushMicrotasks(); + await vi.runAllTimersAsync(); + + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); + + it("drops permanent collector errors without retrying", async () => { + mocks.invoke.mockRejectedValue(new Error("native bridge unavailable")); + 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.invoke.mockRejectedValue(new Error("native bridge unavailable")); + 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 () => { + mocks.invoke.mockRejectedValue(new Error("native bridge unavailable")); + const { trackEvent } = await loadAnalytics(); + trackEvent("export_button_clicked"); + await flushMicrotasks(); + mocks.state.settingsListener?.({ enableTelemetry: false }); + await vi.runAllTimersAsync(); + + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.invoke).toHaveBeenCalledOnce(); + }); + + 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(); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/utils/analytics.ts b/apps/desktop/src/utils/analytics.ts index d50316d3eb0..9a1fb3d0b0e 100644 --- a/apps/desktop/src/utils/analytics.ts +++ b/apps/desktop/src/utils/analytics.ts @@ -1,128 +1,232 @@ +import { + type ClientProductEventNameForPlatform, + isCoreEventName, + isServerOnlyEventName, + normalizeProductEventProperties, + type ProductEventArguments, + type ProductEventInput, +} from "@cap/analytics"; +import { getVersion } from "@tauri-apps/api/app"; +import { invoke } from "@tauri-apps/api/core"; +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( - eventName: string, +async function enqueueProductEvent( + eventId: string, + eventName: ClientProductEventNameForPlatform<"desktop">, + 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( + eventName, + properties, + ); + if (normalizedProperties === null) return; + productAnalyticsQueue.enqueue({ + eventId, + eventName, + occurredAt, + anonymousId, + sessionId: productSessionId, + platform: "desktop", + ...(appVersion ? { appVersion } : {}), + ...(normalizedProperties ? { properties: normalizedProperties } : {}), + }); +} - if (!telemetryEnabledCache) { - return; - } +export function trackEvent< + Name extends ClientProductEventNameForPlatform<"desktop">, +>(eventName: Name, ...args: ProductEventArguments) { + const eventId = uuid(); + const occurredAt = new Date().toISOString(); + if (!isCoreEventName(eventName) || isServerOnlyEventName(eventName)) return; + const normalizedProperties = normalizeProductEventProperties( + eventName, + args[0] as Record | undefined, + ); + if (normalizedProperties === null) return; 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 invoke("capture_client_product_analytics_event", { + eventId, + eventName, + occurredAt, + properties: JSON.stringify(normalizedProperties ?? {}), + }).catch(() => + enqueueProductEvent( + eventId, + eventName, + occurredAt, + normalizedProperties as Record | undefined, + ), + ); }); } -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..559ff090bff 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,8 +216,7 @@ function parseAuthParams(url: URL) { } async function processAuthData(data: AuthParams) { - identifyUser(data.user_id); - trackEvent("user_signed_in", { platform: "desktop" }); + trackEvent("user_signed_in"); await authStore.set({ secret: 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..3eb5e114def --- /dev/null +++ b/apps/desktop/src/utils/product-analytics.test.ts @@ -0,0 +1,267 @@ +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(); + }); + + it("reports incremental drops while retaining the cumulative total", () => { + const onDrop = vi.fn(); + const queue = new ProductAnalyticsQueue({ + sendBatch: sendBatchMock(), + isEnabled: () => true, + batchSize: 10, + capacity: 2, + onDrop, + }); + + queue.enqueue(productEvent(1)); + queue.enqueue(productEvent(2)); + queue.enqueue(productEvent(3)); + queue.clear(); + + expect(onDrop.mock.calls).toEqual([[1], [2]]); + expect(queue.dropped).toBe(3); + }); +}); diff --git a/apps/desktop/src/utils/product-analytics.ts b/apps/desktop/src/utils/product-analytics.ts new file mode 100644 index 00000000000..ee55d76b586 --- /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?.(count); + } + + #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/mobile/app/delete-account.tsx b/apps/mobile/app/delete-account.tsx index 2e6f73ee57a..27b62e44f23 100644 --- a/apps/mobile/app/delete-account.tsx +++ b/apps/mobile/app/delete-account.tsx @@ -37,6 +37,7 @@ export default function DeleteAccountScreen() { setError(null); try { await auth.client.requestAccountDeletion(); + await auth.purgeAnalytics(); await auth.signOut(); router.replace("/(tabs)/account"); Alert.alert( diff --git a/apps/mobile/app/record.tsx b/apps/mobile/app/record.tsx index 481d25efc84..21b03bea8af 100644 --- a/apps/mobile/app/record.tsx +++ b/apps/mobile/app/record.tsx @@ -38,6 +38,11 @@ import { SafeAreaView, useSafeAreaInsets, } from "react-native-safe-area-context"; +import { + classifyMobileAnalyticsFailure, + createMobileProductAnalyticsEventId, + trackMobileProductEventWithId, +} from "@/analytics/product-analytics"; import { apiBaseUrl, useAuth } from "@/auth/AuthContext"; import { getProPlan } from "@/billing/pro"; import { TeleprompterOverlay } from "@/recording/TeleprompterOverlay"; @@ -387,6 +392,7 @@ export default function RecordScreen() { const discardRecording = useRef(false); const activeRecordingId = useRef(null); const recordingStartedAt = useRef(null); + const recordingTerminalTracked = useRef(false); const automaticStopStarted = useRef(false); const screenCompletionStarted = useRef(false); const screenPreparationAttempted = useRef(false); @@ -529,8 +535,10 @@ export default function RecordScreen() { if (!cameraRef.current || !cameraReady || phase !== "ready") return; discardRecording.current = false; automaticStopStarted.current = false; + recordingTerminalTracked.current = false; setError(null); setPhase("starting"); + const attemptId = createMobileProductAnalyticsEventId(); let createdId: string | null = null; try { await loadRecordingDurationLimit(); @@ -548,11 +556,37 @@ export default function RecordScreen() { segmentDurationSeconds: recordingSegmentDurationSeconds, }); recordingStartedAt.current = Date.now(); + await trackMobileProductEventWithId( + `mobile:recording:${created.id}:started`, + new Date(recordingStartedAt.current).toISOString(), + "recording_started", + { + mode: "camera", + target_kind: "camera", + has_camera: true, + has_mic: microphonePermission?.granted === true, + has_system_audio: false, + target_fps: 30, + target_width: 720, + target_height: 1280, + fragmented: true, + custom_cursor_capture: false, + }, + ).catch(() => undefined); setPhase("recording"); void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); setTeleprompterRestartKey((current) => current + 1); setTeleprompterPlaying(hasScript); } catch (recordingError) { + await trackMobileProductEventWithId( + `mobile:recording_attempt:${attemptId}:failed`, + new Date().toISOString(), + "recording_start_failed", + { + mode: "camera", + failure_class: classifyMobileAnalyticsFailure(recordingError), + }, + ).catch(() => undefined); if (createdId) { await recordingUploads.discardRecording(createdId); } @@ -570,6 +604,7 @@ export default function RecordScreen() { cameraReady, hasScript, loadRecordingDurationLimit, + microphonePermission?.granted, phase, recordingUploads, ]); @@ -589,6 +624,7 @@ export default function RecordScreen() { setError(null); setPhase("starting"); screenCompletionStarted.current = false; + const attemptId = createMobileProductAnalyticsEventId(); let createdId: string | null = null; try { const durationLimit = await loadRecordingDurationLimit(); @@ -612,6 +648,15 @@ export default function RecordScreen() { setScreenPrepared(true); setPhase("ready"); } catch (recordingError) { + await trackMobileProductEventWithId( + `mobile:recording_attempt:${attemptId}:failed`, + new Date().toISOString(), + "recording_start_failed", + { + mode: "screen", + failure_class: classifyMobileAnalyticsFailure(recordingError), + }, + ).catch(() => undefined); if (createdId) { await cancelScreenRecording(createdId).catch(() => undefined); await recordingUploads.discardRecording(createdId); @@ -664,6 +709,23 @@ export default function RecordScreen() { if (recordingStartedAt.current === null) { recordingStartedAt.current = Date.now(); } + await trackMobileProductEventWithId( + `mobile:recording:${id}:started`, + new Date(recordingStartedAt.current).toISOString(), + "recording_started", + { + mode: "screen", + target_kind: "display", + has_camera: false, + has_mic: microphonePermission?.granted === true, + has_system_audio: false, + target_fps: 30, + target_width: 720, + target_height: 1280, + fragmented: true, + custom_cursor_capture: false, + }, + ).catch(() => undefined); screenCompletionStarted.current = true; void Haptics.notificationAsync( Haptics.NotificationFeedbackType.Success, @@ -730,7 +792,7 @@ export default function RecordScreen() { } finally { screenUpdateInFlight.current = false; } - }, [finishScreenCaptureUI, recordingUploads]); + }, [finishScreenCaptureUI, microphonePermission?.granted, recordingUploads]); useEffect(() => { if (mode !== "screen" || !screenPrepared || !activeRecordingId.current) { @@ -806,12 +868,48 @@ export default function RecordScreen() { if (!id || !result) throw new Error("The camera did not finish recording."); if (discardRecording.current) { + if (!recordingTerminalTracked.current) { + recordingTerminalTracked.current = true; + await trackMobileProductEventWithId( + `mobile:recording:${id}:completed`, + new Date( + (recordingStartedAt.current ?? Date.now()) + + result.durationSeconds * 1000, + ).toISOString(), + "recording_completed", + { + mode: "camera", + status: "cancelled", + duration_secs: result.durationSeconds, + segment_count: result.segmentCount, + track_failure_count: 0, + }, + ).catch(() => undefined); + } await recordingUploads.discardRecording(id); activeRecordingId.current = null; recordingStartedAt.current = null; router.back(); return; } + if (!recordingTerminalTracked.current) { + recordingTerminalTracked.current = true; + await trackMobileProductEventWithId( + `mobile:recording:${id}:completed`, + new Date( + (recordingStartedAt.current ?? Date.now()) + + result.durationSeconds * 1000, + ).toISOString(), + "recording_completed", + { + mode: "camera", + status: "success", + duration_secs: result.durationSeconds, + segment_count: result.segmentCount, + track_failure_count: 0, + }, + ).catch(() => undefined); + } recordingUploads.finishRecording(id, result); activeRecordingId.current = null; recordingStartedAt.current = null; @@ -821,6 +919,31 @@ export default function RecordScreen() { router.replace("/(tabs)"); return; } catch (recordingError) { + const durationSeconds = recordingStartedAt.current + ? Math.max(0, (Date.now() - recordingStartedAt.current) / 1000) + : 0; + if ( + id && + recordingStartedAt.current !== null && + !recordingTerminalTracked.current + ) { + recordingTerminalTracked.current = true; + await trackMobileProductEventWithId( + `mobile:recording:${id}:completed`, + new Date( + recordingStartedAt.current + durationSeconds * 1000, + ).toISOString(), + "recording_completed", + { + mode: "camera", + status: "failed", + duration_secs: durationSeconds, + segment_count: 0, + track_failure_count: 1, + error_class: classifyMobileAnalyticsFailure(recordingError), + }, + ).catch(() => undefined); + } if (id) { await recordingUploads.discardRecording(id); } @@ -944,6 +1067,29 @@ export default function RecordScreen() { setCameraReady(false); return; } + const durationSeconds = recordingStartedAt.current + ? Math.max(0, (Date.now() - recordingStartedAt.current) / 1000) + : 0; + if (!recordingTerminalTracked.current) { + recordingTerminalTracked.current = true; + void trackMobileProductEventWithId( + `mobile:recording:${id}:completed`, + new Date( + (recordingStartedAt.current ?? Date.now()) + durationSeconds * 1000, + ).toISOString(), + "recording_completed", + { + mode: "camera", + status: "failed", + duration_secs: durationSeconds, + segment_count: 0, + track_failure_count: 1, + error_class: classifyMobileAnalyticsFailure( + new Error(nativeEvent.message), + ), + }, + ).catch(() => undefined); + } activeRecordingId.current = null; recordingStartedAt.current = null; setTeleprompterPlaying(false); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 11f3e26a3f9..ba9601dc3f8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -21,6 +21,7 @@ "update:production": "pnpm dlx eas-cli@21.0.2 update --channel production --environment production" }, "dependencies": { + "@cap/analytics": "workspace:*", "@cap/web-domain": "workspace:*", "@expo/metro-runtime": "~55.0.12", "@shopify/flash-list": "2.0.2", @@ -31,6 +32,7 @@ "expo-camera": "~55.0.21", "expo-clipboard": "~55.0.15", "expo-constants": "~55.0.17", + "expo-crypto": "~55.0.17", "expo-dev-client": "~55.0.37", "expo-device": "~55.0.19", "expo-document-picker": "~55.0.15", @@ -52,7 +54,7 @@ "expo-web-browser": "~55.0.18", "react": "19.2.0", "react-dom": "19.2.0", - "react-native": "0.83.6", + "react-native": "0.83.10", "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", diff --git a/apps/mobile/src/analytics/product-analytics-client.test.ts b/apps/mobile/src/analytics/product-analytics-client.test.ts new file mode 100644 index 00000000000..24011d7536c --- /dev/null +++ b/apps/mobile/src/analytics/product-analytics-client.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, it, vi } from "vitest"; +import { + MobileProductAnalyticsClient, + type MobileProductAnalyticsState, +} from "./product-analytics-client"; + +const createHarness = (input?: { + initialState?: MobileProductAnalyticsState | null; + fetchImpl?: typeof fetch; + queueCapacity?: number; + requestTimeoutMs?: number; + writeStateFails?: boolean; + writeStateImpl?: (state: MobileProductAnalyticsState) => Promise; +}) => { + let state = input?.initialState ?? null; + let id = 0; + let now = Date.parse("2026-07-31T12:00:00.000Z"); + const timers: Array<{ callback: () => void; delayMs: number }> = []; + const client = new MobileProductAnalyticsClient({ + readState: async () => state, + writeState: async (nextState) => { + if (input?.writeStateFails) throw new Error("disk unavailable"); + if (input?.writeStateImpl) { + await input.writeStateImpl(structuredClone(nextState)); + } + state = structuredClone(nextState); + }, + createId: () => `event_${++id}`, + getAppVersion: () => "1.2.3", + fetchImpl: input?.fetchImpl, + now: () => now, + setTimer: (callback, delayMs) => { + timers.push({ callback, delayMs }); + return timers.length as unknown as ReturnType; + }, + clearTimer: vi.fn(), + queueCapacity: input?.queueCapacity, + requestTimeoutMs: input?.requestTimeoutMs, + }); + return { + client, + getState: () => state, + setNow: (value: number) => { + now = value; + }, + timers, + }; +}; + +describe("MobileProductAnalyticsClient", () => { + it("persists the original event id through retry and restart", async () => { + const firstFetch = vi.fn(() => + Promise.reject(new Error("offline")), + ); + const first = createHarness({ fetchImpl: firstFetch }); + await first.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + const eventId = await first.client.track("multipart_upload_complete", { + duration: 12, + length: 12, + size: 1024, + }); + await first.client.configure({ + apiKey: "mobile_key", + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + const afterRetry = await first.client.snapshot(); + expect(afterRetry.pending[0]?.event.eventId).toBe(eventId); + expect(afterRetry.pending[0]?.attempts).toBe(1); + expect(afterRetry.delivery.retried).toBe(1); + + const acceptedBodies: unknown[] = []; + const second = createHarness({ + initialState: first.getState(), + fetchImpl: vi.fn(async (_url, init) => { + acceptedBodies.push(JSON.parse(String(init?.body)) as unknown); + return Response.json({ accepted: 1 }); + }), + }); + second.setNow(Date.parse("2026-07-31T12:00:02.000Z")); + await second.client.configure({ + apiKey: "mobile_key", + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + expect( + ( + acceptedBodies[0] as { + events: Array<{ eventId: string }>; + } + ).events[0]?.eventId, + ).toBe(eventId); + expect((await second.client.snapshot()).pending).toEqual([]); + }); + + it("moves permanent contract failures into a visible dead letter", async () => { + const harness = createHarness({ + fetchImpl: vi.fn( + async () => new Response(null, { status: 400 }), + ), + }); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + const eventId = await harness.client.track("user_signed_in"); + await harness.client.configure({ + apiKey: "mobile_key", + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + const snapshot = await harness.client.snapshot(); + expect(snapshot.pending).toEqual([]); + expect(snapshot.delivery.contract_rejected).toBe(1); + expect(snapshot.deadLetters).toEqual([ + expect.objectContaining({ + eventId, + reason: "contract", + status: 400, + }), + ]); + }); + + it.each([404, 410, 429, 500])( + "retries a retryable %s response", + async (status) => { + const harness = createHarness({ + fetchImpl: vi.fn( + async () => new Response(null, { status }), + ), + }); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + const eventId = await harness.client.track("user_signed_in"); + await harness.client.configure({ + apiKey: "mobile_key", + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + const snapshot = await harness.client.snapshot(); + expect(snapshot.pending[0]?.event.eventId).toBe(eventId); + expect(snapshot.pending[0]?.attempts).toBe(1); + expect(snapshot.delivery.retried).toBe(1); + }, + ); + + it("finalizes accepted rows without losing a rejected batch neighbor", async () => { + let acceptedEventId = ""; + let rejectedEventId = ""; + const harness = createHarness({ + fetchImpl: vi.fn(async () => + Response.json({ + accepted: 1, + acceptedEventIds: [acceptedEventId], + rejectedEventIds: [rejectedEventId], + }), + ), + }); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + acceptedEventId = await harness.client.track("user_signed_in"); + rejectedEventId = await harness.client.track("user_signed_in"); + await harness.client.configure({ + apiKey: "mobile_key", + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + const snapshot = await harness.client.snapshot(); + expect(snapshot.pending).toEqual([]); + expect(snapshot.delivery).toMatchObject({ + accepted: 1, + contract_rejected: 1, + }); + expect(snapshot.deadLetters).toEqual([ + expect.objectContaining({ + eventId: rejectedEventId, + reason: "contract", + status: 409, + }), + ]); + }); + + it("aborts and retries a request that exceeds the timeout", async () => { + const harness = createHarness({ + requestTimeoutMs: 250, + fetchImpl: vi.fn( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + }), + ), + }); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + const eventId = await harness.client.track("user_signed_in"); + const configuring = harness.client.configure({ + apiKey: "mobile_key", + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + await vi.waitFor(() => { + expect(harness.timers.some((timer) => timer.delayMs === 250)).toBe(true); + }); + harness.timers.find((timer) => timer.delayMs === 250)?.callback(); + await configuring; + const snapshot = await harness.client.snapshot(); + expect(snapshot.pending[0]?.event.eventId).toBe(eventId); + expect(snapshot.pending[0]?.attempts).toBe(1); + expect(snapshot.delivery.retried).toBe(1); + }); + + it("dead-letters a critical event instead of silently dropping on overflow", async () => { + const harness = createHarness({ queueCapacity: 1 }); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + const displacedId = await harness.client.track( + "multipart_upload_complete", + { duration: 1, length: 1, size: 1 }, + ); + await harness.client.track("recording_started", { + mode: "camera", + target_kind: "camera", + has_camera: true, + has_mic: true, + has_system_audio: false, + target_fps: 30, + target_width: 720, + target_height: 1280, + fragmented: true, + custom_cursor_capture: false, + }); + const snapshot = await harness.client.snapshot(); + expect(snapshot.pending).toHaveLength(1); + expect(snapshot.delivery.queue_overflow).toBe(1); + expect(snapshot.delivery.dropped).toBe(0); + expect(snapshot.deadLetters).toEqual([ + expect.objectContaining({ + eventId: displacedId, + reason: "queue_overflow", + }), + ]); + }); + + it("reports observable best-effort loss on overflow", async () => { + const harness = createHarness({ queueCapacity: 1 }); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + await harness.client.track("user_signed_out"); + await harness.client.track("user_signed_out"); + const snapshot = await harness.client.snapshot(); + expect(snapshot.pending[0]?.event.eventName).toBe("user_signed_out"); + expect(snapshot.delivery.queue_overflow).toBe(1); + expect(snapshot.delivery.dropped).toBe(1); + expect(snapshot.deadLetters).toEqual([]); + }); + + it("isolates identities and pending events across accounts", async () => { + const harness = createHarness(); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + await harness.client.track("multipart_upload_complete", { + duration: 1, + length: 1, + size: 1, + }); + const firstSnapshot = await harness.client.snapshot(); + const firstAnonymousId = firstSnapshot.pending[0]?.event.anonymousId; + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_2", + baseUrl: "https://cap.so", + }); + await harness.client.track("user_signed_in"); + const snapshot = await harness.client.snapshot(); + expect(snapshot.pending).toHaveLength(2); + expect(snapshot.pending[0]?.credentialScope).toBe("scope_1"); + expect(snapshot.pending[1]?.credentialScope).toBe("scope_2"); + expect(snapshot.pending[1]?.event.anonymousId).not.toBe(firstAnonymousId); + + await harness.client.purgeCredentialScope("scope_1"); + const purged = await harness.client.snapshot(); + expect(purged.pending).toHaveLength(1); + expect(purged.pending[0]?.credentialScope).toBe("scope_2"); + expect(purged.anonymousIds.scope_1).toBeUndefined(); + }); + + it("retains finalized ids and rejects changed payloads after acceptance", async () => { + const harness = createHarness({ + fetchImpl: vi.fn(async () => + Response.json({ accepted: 1 }), + ), + }); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + await harness.client.trackWithId( + "stable_event", + "2026-07-31T12:00:00.000Z", + "multipart_upload_complete", + { duration: 1, length: 1, size: 1 }, + ); + await harness.client.configure({ + apiKey: "mobile_key", + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + expect((await harness.client.snapshot()).eventLedger).toEqual([ + expect.objectContaining({ eventId: "stable_event", outcome: "accepted" }), + ]); + await expect( + harness.client.trackWithId( + "stable_event", + "2026-07-31T12:00:00.000Z", + "multipart_upload_complete", + { duration: 2, length: 1, size: 1 }, + ), + ).rejects.toThrow("Conflicting mobile product analytics event id"); + }); + + it("keeps credential failures pending for same-account reauthentication", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce(Response.json({ accepted: 1 })); + const harness = createHarness({ fetchImpl }); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + await harness.client.track("user_signed_in"); + await harness.client.configure({ + apiKey: "expired_key", + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + expect((await harness.client.snapshot()).pending).toHaveLength(1); + harness.setNow(Date.parse("2026-07-31T12:00:02.000Z")); + await harness.client.configure({ + apiKey: "replacement_key", + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + expect((await harness.client.snapshot()).pending).toEqual([]); + }); + + it("serializes persistence under concurrent tracking", async () => { + let activeWrites = 0; + let maxActiveWrites = 0; + const harness = createHarness({ + writeStateImpl: async () => { + activeWrites += 1; + maxActiveWrites = Math.max(maxActiveWrites, activeWrites); + await Promise.resolve(); + activeWrites -= 1; + }, + }); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + await Promise.all([ + harness.client.track("user_signed_in"), + harness.client.track("user_signed_out"), + ]); + expect(maxActiveWrites).toBe(1); + expect((await harness.client.snapshot()).pending).toHaveLength(2); + }); + + it("rejects the same pending event id with a different payload", async () => { + const harness = createHarness(); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + await harness.client.trackWithId( + "stable_event", + "2026-07-31T12:00:00.000Z", + "multipart_upload_complete", + { duration: 1, length: 1, size: 1 }, + ); + await expect( + harness.client.trackWithId( + "stable_event", + "2026-07-31T12:00:00.000Z", + "multipart_upload_complete", + { duration: 2, length: 1, size: 1 }, + ), + ).rejects.toThrow("Conflicting mobile product analytics event id"); + }); + + it("does not acknowledge a critical event when local persistence fails", async () => { + const harness = createHarness({ writeStateFails: true }); + await harness.client.configure({ + apiKey: null, + credentialScope: "scope_1", + baseUrl: "https://cap.so", + }); + await expect( + harness.client.track("multipart_upload_complete", { + duration: 1, + length: 1, + size: 1, + }), + ).rejects.toThrow("Critical mobile analytics persistence failed"); + const snapshot = await harness.client.snapshot(); + expect(snapshot.pending).toHaveLength(1); + expect(snapshot.delivery.persistence_failed).toBe(1); + }); +}); diff --git a/apps/mobile/src/analytics/product-analytics-client.ts b/apps/mobile/src/analytics/product-analytics-client.ts new file mode 100644 index 00000000000..1a5eed7b93d --- /dev/null +++ b/apps/mobile/src/analytics/product-analytics-client.ts @@ -0,0 +1,690 @@ +import { + type ClientProductEventNameForPlatform, + createProductEventPayloadHash, + getProductEventDefinition, + normalizeProductEventInput, + PRODUCT_ANALYTICS_LIMITS, + type ProductEventArguments, + type ProductEventInput, +} from "@cap/analytics"; + +export type MobileProductEventName = + ClientProductEventNameForPlatform<"mobile">; + +type PendingEvent = { + event: ProductEventInput; + credentialScope: string; + attempts: number; + nextAttemptAt: number; +}; + +type DeadLetter = { + eventId: string; + eventName: string; + credentialScope?: string; + failedAt: string; + reason: + | "contract" + | "identity_changed" + | "queue_overflow" + | "storage_corrupt"; + status?: number; +}; + +type EventLedgerEntry = { + eventId: string; + payloadHash: string; + credentialScope: string; + outcome: "accepted" | "dead_letter" | "dropped"; + finalizedAt: string; +}; + +type DeliveryCounters = { + attempted: number; + accepted: number; + retried: number; + dropped: number; + queue_overflow: number; + oversize: number; + contract_rejected: number; + persistence_failed: number; +}; + +export type MobileProductAnalyticsState = { + version: 2; + anonymousIds: Record; + pending: PendingEvent[]; + deadLetters: DeadLetter[]; + deadLetterEvicted: number; + eventLedger: EventLedgerEntry[]; + delivery: DeliveryCounters; +}; + +type MobileProductAnalyticsClientOptions = { + readState: () => Promise; + writeState: (state: MobileProductAnalyticsState) => Promise; + createId: () => string; + getAppVersion: () => string | undefined; + fetchImpl?: typeof fetch; + now?: () => number; + setTimer?: ( + callback: () => void, + delayMs: number, + ) => ReturnType; + clearTimer?: (timer: ReturnType) => void; + queueCapacity?: number; + deadLetterCapacity?: number; + eventLedgerCapacity?: number; + requestTimeoutMs?: number; +}; + +const emptyCounters = (): DeliveryCounters => ({ + attempted: 0, + accepted: 0, + retried: 0, + dropped: 0, + queue_overflow: 0, + oversize: 0, + contract_rejected: 0, + persistence_failed: 0, +}); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const safeCount = (value: unknown) => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : 0; + +const hydrateState = ( + value: unknown, + createId: () => string, + now: number, +): MobileProductAnalyticsState => { + const state: MobileProductAnalyticsState = { + version: 2, + anonymousIds: {}, + pending: [], + deadLetters: [], + deadLetterEvicted: 0, + eventLedger: [], + delivery: emptyCounters(), + }; + if (!isRecord(value) || ![1, 2].includes(Number(value.version))) return state; + if (value.version === 2 && isRecord(value.anonymousIds)) { + for (const [credentialScope, anonymousId] of Object.entries( + value.anonymousIds, + )) { + if ( + credentialScope && + typeof anonymousId === "string" && + anonymousId.length > 0 + ) { + state.anonymousIds[credentialScope] = anonymousId; + } + } + } + if (isRecord(value.delivery)) { + for (const key of Object.keys(state.delivery) as Array< + keyof DeliveryCounters + >) { + state.delivery[key] = safeCount(value.delivery[key]); + } + } + state.deadLetterEvicted = safeCount(value.deadLetterEvicted); + if (Array.isArray(value.deadLetters)) { + state.deadLetters = value.deadLetters.flatMap((entry) => { + if ( + !isRecord(entry) || + typeof entry.eventId !== "string" || + typeof entry.eventName !== "string" || + typeof entry.failedAt !== "string" || + ![ + "contract", + "identity_changed", + "queue_overflow", + "storage_corrupt", + ].includes(String(entry.reason)) + ) { + return []; + } + return [entry as DeadLetter]; + }); + } + if (Array.isArray(value.eventLedger)) { + state.eventLedger = value.eventLedger.flatMap((entry) => { + if ( + !isRecord(entry) || + typeof entry.eventId !== "string" || + typeof entry.payloadHash !== "string" || + typeof entry.credentialScope !== "string" || + typeof entry.finalizedAt !== "string" || + !["accepted", "dead_letter", "dropped"].includes(String(entry.outcome)) + ) { + return []; + } + return [entry as EventLedgerEntry]; + }); + } + if (Array.isArray(value.pending)) { + for (const entry of value.pending) { + if (!isRecord(entry)) continue; + if ( + typeof entry.credentialScope !== "string" || + entry.credentialScope.length === 0 + ) { + continue; + } + const event = normalizeProductEventInput(entry.event, now); + if (!event || event.platform !== "mobile") { + const stored = isRecord(entry.event) ? entry.event : {}; + state.deadLetters.push({ + eventId: + typeof stored.eventId === "string" ? stored.eventId : createId(), + eventName: + typeof stored.eventName === "string" ? stored.eventName : "unknown", + failedAt: new Date(now).toISOString(), + reason: "storage_corrupt", + }); + continue; + } + state.pending.push({ + event, + credentialScope: entry.credentialScope, + attempts: safeCount(entry.attempts), + nextAttemptAt: safeCount(entry.nextAttemptAt), + }); + } + } + return state; +}; + +export class MobileProductAnalyticsClient { + readonly #options: Required< + Pick< + MobileProductAnalyticsClientOptions, + | "fetchImpl" + | "now" + | "setTimer" + | "clearTimer" + | "queueCapacity" + | "deadLetterCapacity" + | "eventLedgerCapacity" + | "requestTimeoutMs" + > + > & + Omit< + MobileProductAnalyticsClientOptions, + | "fetchImpl" + | "now" + | "setTimer" + | "clearTimer" + | "queueCapacity" + | "deadLetterCapacity" + | "eventLedgerCapacity" + | "requestTimeoutMs" + >; + #state: MobileProductAnalyticsState | null = null; + #initializing: Promise | null = null; + #flushing: Promise | null = null; + #mutationQueue: Promise = Promise.resolve(); + #timer: ReturnType | null = null; + #apiKey: string | null = null; + #credentialScope: string | null = null; + #baseUrl: string | null = null; + + constructor(options: MobileProductAnalyticsClientOptions) { + this.#options = { + ...options, + fetchImpl: options.fetchImpl ?? fetch, + now: options.now ?? Date.now, + setTimer: options.setTimer ?? setTimeout, + clearTimer: options.clearTimer ?? clearTimeout, + queueCapacity: options.queueCapacity ?? 500, + deadLetterCapacity: options.deadLetterCapacity ?? 100, + eventLedgerCapacity: options.eventLedgerCapacity ?? 1_000, + requestTimeoutMs: options.requestTimeoutMs ?? 10_000, + }; + } + + async configure(input: { + apiKey: string | null; + credentialScope: string | null; + baseUrl: string; + }) { + await this.#serialize(async () => { + this.#apiKey = input.apiKey; + this.#credentialScope = input.credentialScope; + this.#baseUrl = input.baseUrl; + await this.#initialize(); + }); + if (input.apiKey) await this.flush(); + } + + async track( + eventName: Name, + ...args: ProductEventArguments + ) { + return this.trackWithId( + this.#options.createId(), + new Date(this.#options.now()).toISOString(), + eventName, + ...args, + ); + } + + async trackWithId( + eventId: string, + occurredAt: string, + eventName: Name, + ...args: ProductEventArguments + ) { + const trackedEventId = await this.#serialize(async () => { + await this.#initialize(); + const state = this.#requireState(); + const credentialScope = this.#credentialScope; + if (!credentialScope) { + throw new Error("Mobile analytics requires an authenticated scope"); + } + const anonymousId = + state.anonymousIds[credentialScope] ?? this.#options.createId(); + state.anonymousIds[credentialScope] = anonymousId; + const event = normalizeProductEventInput( + { + eventId, + eventName, + occurredAt, + anonymousId, + platform: "mobile", + appVersion: this.#options.getAppVersion(), + ...(args[0] ? { properties: args[0] } : {}), + }, + this.#options.now(), + ); + if (!event) throw new Error("Invalid mobile product analytics event"); + const payloadHash = createProductEventPayloadHash(event); + const existing = state.pending.find( + (entry) => entry.event.eventId === event.eventId, + ); + const finalized = state.eventLedger.find( + (entry) => entry.eventId === event.eventId, + ); + if ( + (existing && + (existing.credentialScope !== credentialScope || + createProductEventPayloadHash(existing.event) !== payloadHash)) || + (finalized && + (finalized.credentialScope !== credentialScope || + finalized.payloadHash !== payloadHash)) + ) { + throw new Error("Conflicting mobile product analytics event id"); + } + if (finalized) return event.eventId; + if (!existing) { + this.#makeRoom(); + state.pending.push({ + event, + credentialScope, + attempts: 0, + nextAttemptAt: 0, + }); + } + const persisted = await this.#persist(); + if ( + !persisted && + getProductEventDefinition(event.eventName).delivery === "critical" + ) { + throw new Error("Critical mobile analytics persistence failed"); + } + return event.eventId; + }); + void this.flush(); + return trackedEventId; + } + + async flush() { + if (this.#flushing) return this.#flushing; + this.#flushing = this.#serialize(async () => { + await this.#initialize(); + if (!this.#apiKey || !this.#baseUrl) return; + await this.#flushLoop(); + }).finally(() => { + this.#flushing = null; + }); + return this.#flushing; + } + + async snapshot() { + return this.#serialize(async () => { + await this.#initialize(); + return structuredClone(this.#requireState()); + }); + } + + async purgeCredentialScope(credentialScope: string) { + await this.#serialize(async () => { + await this.#initialize(); + const state = this.#requireState(); + state.pending = state.pending.filter( + (entry) => entry.credentialScope !== credentialScope, + ); + state.deadLetters = state.deadLetters.filter( + (entry) => + Boolean(entry.credentialScope) && + entry.credentialScope !== credentialScope, + ); + state.eventLedger = state.eventLedger.filter( + (entry) => entry.credentialScope !== credentialScope, + ); + delete state.anonymousIds[credentialScope]; + const persisted = await this.#persist(); + if (!persisted) throw new Error("Mobile analytics purge failed"); + }); + } + + #serialize(operation: () => Promise) { + const result = this.#mutationQueue.then(operation, operation); + this.#mutationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async #initialize() { + if (this.#state) return; + if (!this.#initializing) { + this.#initializing = this.#options + .readState() + .then((value) => { + this.#state = hydrateState( + value, + this.#options.createId, + this.#options.now(), + ); + this.#trimDeadLetters(); + }) + .catch(() => { + this.#state = hydrateState( + null, + this.#options.createId, + this.#options.now(), + ); + }); + } + await this.#initializing; + } + + #requireState() { + if (!this.#state) throw new Error("Mobile analytics is not initialized"); + return this.#state; + } + + #makeRoom() { + const state = this.#requireState(); + if (state.pending.length < this.#options.queueCapacity) return; + const bestEffortIndex = state.pending.findIndex( + (entry) => + getProductEventDefinition(entry.event.eventName).delivery === + "best_effort", + ); + const index = bestEffortIndex >= 0 ? bestEffortIndex : 0; + const [removed] = state.pending.splice(index, 1); + state.delivery.queue_overflow += 1; + if ( + removed && + getProductEventDefinition(removed.event.eventName).delivery === "critical" + ) { + this.#deadLetter( + removed.event, + "queue_overflow", + undefined, + removed.credentialScope, + ); + } else { + state.delivery.dropped += 1; + } + if (removed) { + this.#recordFinalizedEvent( + removed.event, + removed.credentialScope, + getProductEventDefinition(removed.event.eventName).delivery === + "critical" + ? "dead_letter" + : "dropped", + ); + } + } + + async #flushLoop() { + while (this.#apiKey && this.#baseUrl && this.#credentialScope) { + const state = this.#requireState(); + const now = this.#options.now(); + const batch = state.pending + .filter( + (entry) => + entry.credentialScope === this.#credentialScope && + entry.nextAttemptAt <= now, + ) + .slice(0, PRODUCT_ANALYTICS_LIMITS.batchSize); + if (batch.length === 0) { + this.#scheduleNext(); + return; + } + state.delivery.attempted += batch.length; + await this.#persist(); + let response: Response; + const abortController = new AbortController(); + const requestTimer = this.#options.setTimer( + () => abortController.abort(), + this.#options.requestTimeoutMs, + ); + try { + response = await this.#options.fetchImpl( + new URL("/api/events", this.#baseUrl), + { + method: "POST", + headers: { + Authorization: `Bearer ${this.#apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + events: batch.map((entry) => entry.event), + delivery: state.delivery, + }), + signal: abortController.signal, + }, + ); + } catch { + await this.#retry(batch); + return; + } finally { + this.#options.clearTimer(requestTimer); + } + if (response.ok) { + const payload = (await response.json().catch(() => null)) as unknown; + if (!isRecord(payload)) { + await this.#retry(batch); + return; + } + const requestedIds = new Set(batch.map((entry) => entry.event.eventId)); + const rawAcceptedEventIds = Array.isArray(payload.acceptedEventIds) + ? payload.acceptedEventIds + : undefined; + const rawRejectedEventIds = Array.isArray(payload.rejectedEventIds) + ? payload.rejectedEventIds + : undefined; + const hasSelectiveResult = Boolean( + rawAcceptedEventIds && rawRejectedEventIds, + ); + const acceptedEventIds = hasSelectiveResult + ? (rawAcceptedEventIds ?? []).filter( + (eventId: unknown): eventId is string => + typeof eventId === "string" && requestedIds.has(eventId), + ) + : payload.accepted === batch.length + ? [...requestedIds] + : []; + const rejectedEventIds = hasSelectiveResult + ? (rawRejectedEventIds ?? []).filter( + (eventId: unknown): eventId is string => + typeof eventId === "string" && requestedIds.has(eventId), + ) + : []; + if ( + payload.accepted !== acceptedEventIds.length || + acceptedEventIds.length + rejectedEventIds.length !== + requestedIds.size || + new Set([...acceptedEventIds, ...rejectedEventIds]).size !== + requestedIds.size + ) { + await this.#retry(batch); + return; + } + const ids = new Set([...acceptedEventIds, ...rejectedEventIds]); + state.pending = state.pending.filter( + (entry) => !ids.has(entry.event.eventId), + ); + state.delivery.accepted += acceptedEventIds.length; + state.delivery.contract_rejected += rejectedEventIds.length; + for (const entry of batch) { + if (acceptedEventIds.includes(entry.event.eventId)) { + this.#recordFinalizedEvent( + entry.event, + entry.credentialScope, + "accepted", + ); + } else { + this.#deadLetter( + entry.event, + "contract", + 409, + entry.credentialScope, + ); + this.#recordFinalizedEvent( + entry.event, + entry.credentialScope, + "dead_letter", + ); + } + } + await this.#persist(); + continue; + } + if ( + response.status === 401 || + response.status === 403 || + response.status === 404 || + response.status === 410 || + response.status === 429 || + response.status >= 500 + ) { + await this.#retry(batch); + return; + } + const ids = new Set(batch.map((entry) => entry.event.eventId)); + state.pending = state.pending.filter( + (entry) => !ids.has(entry.event.eventId), + ); + state.delivery.contract_rejected += batch.length; + for (const entry of batch) { + this.#deadLetter( + entry.event, + "contract", + response.status, + entry.credentialScope, + ); + this.#recordFinalizedEvent( + entry.event, + entry.credentialScope, + "dead_letter", + ); + } + await this.#persist(); + } + } + + async #retry(batch: PendingEvent[]) { + const now = this.#options.now(); + for (const entry of batch) { + entry.attempts += 1; + entry.nextAttemptAt = + now + Math.min(5 * 60_000, 1000 * 2 ** Math.min(entry.attempts - 1, 8)); + } + this.#requireState().delivery.retried += batch.length; + await this.#persist(); + this.#scheduleNext(); + } + + #scheduleNext() { + if (this.#timer) this.#options.clearTimer(this.#timer); + const nextAttemptAt = this.#requireState().pending.reduce( + (next, entry) => Math.min(next, entry.nextAttemptAt), + Number.POSITIVE_INFINITY, + ); + if (!Number.isFinite(nextAttemptAt)) return; + this.#timer = this.#options.setTimer( + () => { + this.#timer = null; + void this.flush(); + }, + Math.max(0, nextAttemptAt - this.#options.now()), + ); + } + + #deadLetter( + event: ProductEventInput, + reason: DeadLetter["reason"], + status?: number, + credentialScope?: string, + ) { + this.#requireState().deadLetters.push({ + eventId: event.eventId, + eventName: event.eventName, + ...(credentialScope ? { credentialScope } : {}), + failedAt: new Date(this.#options.now()).toISOString(), + reason, + ...(status ? { status } : {}), + }); + this.#trimDeadLetters(); + } + + #recordFinalizedEvent( + event: ProductEventInput, + credentialScope: string, + outcome: EventLedgerEntry["outcome"], + ) { + const state = this.#requireState(); + state.eventLedger = state.eventLedger.filter( + (entry) => entry.eventId !== event.eventId, + ); + state.eventLedger.push({ + eventId: event.eventId, + payloadHash: createProductEventPayloadHash(event), + credentialScope, + outcome, + finalizedAt: new Date(this.#options.now()).toISOString(), + }); + const excess = state.eventLedger.length - this.#options.eventLedgerCapacity; + if (excess > 0) state.eventLedger.splice(0, excess); + } + + #trimDeadLetters() { + const state = this.#requireState(); + const excess = state.deadLetters.length - this.#options.deadLetterCapacity; + if (excess <= 0) return; + state.deadLetters.splice(0, excess); + state.deadLetterEvicted += excess; + } + + async #persist() { + try { + await this.#options.writeState(structuredClone(this.#requireState())); + return true; + } catch { + this.#requireState().delivery.persistence_failed += 1; + return false; + } + } +} diff --git a/apps/mobile/src/analytics/product-analytics-storage.test.ts b/apps/mobile/src/analytics/product-analytics-storage.test.ts new file mode 100644 index 00000000000..68be0361d31 --- /dev/null +++ b/apps/mobile/src/analytics/product-analytics-storage.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MobileProductAnalyticsState } from "./product-analytics-client"; +import { createMobileProductAnalyticsStorage } from "./product-analytics-storage"; + +const state = (attempted: number) => + ({ + version: 2, + anonymousIds: {}, + pending: [], + deadLetters: [], + deadLetterEvicted: 0, + eventLedger: [], + delivery: { + attempted, + accepted: 0, + retried: 0, + dropped: 0, + queue_overflow: 0, + oversize: 0, + contract_rejected: 0, + persistence_failed: 0, + }, + }) satisfies MobileProductAnalyticsState; + +const createHarness = () => { + const files = new Map(); + let failFinalMove = false; + const fileSystem = { + writeAsStringAsync: vi.fn(async (uri: string, contents: string) => { + files.set(uri, contents); + }), + readAsStringAsync: vi.fn(async (uri: string) => { + const contents = files.get(uri); + if (contents === undefined) throw new Error("missing"); + return contents; + }), + deleteAsync: vi.fn(async (uri: string) => { + files.delete(uri); + }), + getInfoAsync: vi.fn(async (uri: string) => ({ exists: files.has(uri) })), + moveAsync: vi.fn(async ({ from, to }: { from: string; to: string }) => { + if (failFinalMove && from.endsWith(".next")) + throw new Error("interrupted"); + const contents = files.get(from); + if (contents === undefined) throw new Error("missing"); + files.set(to, contents); + files.delete(from); + }), + }; + return { + files, + storage: createMobileProductAnalyticsStorage(fileSystem, "file:///docs/"), + failFinalMove: () => { + failFinalMove = true; + }, + }; +}; + +describe("mobile product analytics storage", () => { + it("recovers the last valid state after an interrupted rotation", async () => { + const harness = createHarness(); + await harness.storage.writeState(state(1)); + harness.failFinalMove(); + await expect(harness.storage.writeState(state(2))).rejects.toThrow( + "interrupted", + ); + expect(await harness.storage.readState()).toEqual(state(1)); + }); + + it("falls back from corrupt current state to a valid backup", async () => { + const harness = createHarness(); + harness.files.set( + "file:///docs/product-analytics-outbox-v1.json", + "invalid", + ); + harness.files.set( + "file:///docs/product-analytics-outbox-v1.json.backup", + JSON.stringify(state(3)), + ); + expect(await harness.storage.readState()).toEqual(state(3)); + }); +}); diff --git a/apps/mobile/src/analytics/product-analytics-storage.ts b/apps/mobile/src/analytics/product-analytics-storage.ts new file mode 100644 index 00000000000..8a10961d3e9 --- /dev/null +++ b/apps/mobile/src/analytics/product-analytics-storage.ts @@ -0,0 +1,49 @@ +import type { MobileProductAnalyticsState } from "./product-analytics-client"; + +type ProductAnalyticsFileSystem = { + writeAsStringAsync: (uri: string, contents: string) => Promise; + readAsStringAsync: (uri: string) => Promise; + deleteAsync: (uri: string, options: { idempotent: boolean }) => Promise; + getInfoAsync: (uri: string) => Promise<{ exists: boolean }>; + moveAsync: (options: { from: string; to: string }) => Promise; +}; + +export function createMobileProductAnalyticsStorage( + fileSystem: ProductAnalyticsFileSystem, + documentDirectory: string | null, +) { + const stateUri = documentDirectory + ? `${documentDirectory}product-analytics-outbox-v1.json` + : null; + const nextStateUri = stateUri ? `${stateUri}.next` : null; + const backupStateUri = stateUri ? `${stateUri}.backup` : null; + const readJsonFile = async (uri: string) => + JSON.parse(await fileSystem.readAsStringAsync(uri)) as unknown; + + const readState = async () => { + if (!stateUri) return null; + for (const uri of [stateUri, backupStateUri, nextStateUri]) { + if (!uri) continue; + try { + return await readJsonFile(uri); + } catch {} + } + return null; + }; + + const writeState = async (state: MobileProductAnalyticsState) => { + if (!stateUri || !nextStateUri || !backupStateUri) { + throw new Error("Mobile analytics storage is unavailable"); + } + await fileSystem.writeAsStringAsync(nextStateUri, JSON.stringify(state)); + await readJsonFile(nextStateUri); + await fileSystem.deleteAsync(backupStateUri, { idempotent: true }); + const current = await fileSystem.getInfoAsync(stateUri); + if (current.exists) { + await fileSystem.moveAsync({ from: stateUri, to: backupStateUri }); + } + await fileSystem.moveAsync({ from: nextStateUri, to: stateUri }); + }; + + return { readState, writeState }; +} diff --git a/apps/mobile/src/analytics/product-analytics.ts b/apps/mobile/src/analytics/product-analytics.ts new file mode 100644 index 00000000000..4b0ec2c8124 --- /dev/null +++ b/apps/mobile/src/analytics/product-analytics.ts @@ -0,0 +1,78 @@ +import type { ProductEventArguments } from "@cap/analytics"; +import Constants from "expo-constants"; +import * as Crypto from "expo-crypto"; +import * as FileSystem from "expo-file-system/legacy"; +import { + MobileProductAnalyticsClient, + type MobileProductEventName, +} from "./product-analytics-client"; +import { createMobileProductAnalyticsStorage } from "./product-analytics-storage"; + +const storage = createMobileProductAnalyticsStorage( + FileSystem, + FileSystem.documentDirectory, +); + +const client = new MobileProductAnalyticsClient({ + readState: storage.readState, + writeState: storage.writeState, + createId: Crypto.randomUUID, + getAppVersion: () => Constants.expoConfig?.version ?? undefined, +}); + +const credentialScopeForUserId = (userId: string) => + Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, userId); + +export const configureMobileProductAnalytics = (input: { + apiKey: string | null; + userId: string | null; + baseUrl: string; +}) => + Promise.resolve( + input.userId ? credentialScopeForUserId(input.userId) : null, + ).then((credentialScope) => + client.configure({ + apiKey: input.apiKey, + credentialScope, + baseUrl: input.baseUrl, + }), + ); + +export const purgeMobileProductAnalytics = (userId: string) => + credentialScopeForUserId(userId).then((credentialScope) => + client.purgeCredentialScope(credentialScope), + ); + +export const flushMobileProductAnalytics = () => client.flush(); + +export const createMobileProductAnalyticsEventId = () => Crypto.randomUUID(); + +export const trackMobileProductEvent = ( + eventName: Name, + ...args: ProductEventArguments +) => client.track(eventName, ...args); + +export const trackMobileProductEventWithId = < + Name extends MobileProductEventName, +>( + eventId: string, + occurredAt: string, + eventName: Name, + ...args: ProductEventArguments +) => client.trackWithId(eventId, occurredAt, eventName, ...args); + +export const getMobileProductAnalyticsHealth = () => client.snapshot(); + +export const classifyMobileAnalyticsFailure = (error: unknown) => { + const value = error instanceof Error ? error.message.toLowerCase() : ""; + if (value.includes("timeout") || value.includes("timed out")) + return "timeout"; + if (value.includes("permission") || value.includes("denied")) { + return "permission"; + } + if (value.includes("network") || value.includes("connect")) return "network"; + if (value.includes("storage") || value.includes("disk")) return "storage"; + if (value.includes("format") || value.includes("media")) + return "invalid_media"; + return "unknown"; +}; diff --git a/apps/mobile/src/auth/AuthContext.tsx b/apps/mobile/src/auth/AuthContext.tsx index 7c4902a5b97..2664292bfb9 100644 --- a/apps/mobile/src/auth/AuthContext.tsx +++ b/apps/mobile/src/auth/AuthContext.tsx @@ -11,6 +11,13 @@ import { useMemo, useState, } from "react"; +import { AppState } from "react-native"; +import { + configureMobileProductAnalytics, + flushMobileProductAnalytics, + purgeMobileProductAnalytics, + trackMobileProductEvent, +} from "@/analytics/product-analytics"; import { createMobileApiClient, createSessionRequestUrl, @@ -41,6 +48,7 @@ type AuthState = { signInWithGoogle: () => Promise; signInWithSso: (organizationId: string) => Promise; signOut: () => Promise; + purgeAnalytics: () => Promise; refresh: () => Promise; setActiveOrganization: (organizationId: string) => Promise; }; @@ -153,6 +161,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { } setApiKey(session.apiKey); setUserId(session.userId); + await configureMobileProductAnalytics({ + apiKey: session.apiKey, + userId: session.userId, + baseUrl: apiBaseUrl, + }); + if (session.userId) await trackMobileProductEvent("user_signed_in"); }, [], ); @@ -219,6 +233,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const signOut = useCallback(async () => { + if (apiKey && userId) { + await trackMobileProductEvent("user_signed_out").catch(() => undefined); + } if (apiKey) { await client.revokeSession().catch(() => {}); } @@ -229,7 +246,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { setApiKey(null); setUserId(null); setBootstrap(null); - }, [apiKey, client]); + await configureMobileProductAnalytics({ + apiKey: null, + userId: null, + baseUrl: apiBaseUrl, + }); + }, [apiKey, client, userId]); + + const purgeAnalytics = useCallback(async () => { + if (userId) await purgeMobileProductAnalytics(userId); + }, [userId]); + + useEffect(() => { + void configureMobileProductAnalytics({ + apiKey, + userId, + baseUrl: apiBaseUrl, + }); + }, [apiKey, userId]); + + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") void flushMobileProductAnalytics(); + }); + return () => subscription.remove(); + }, []); const setActiveOrganization = useCallback( async (organizationId: string) => { @@ -253,6 +294,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { signInWithGoogle, signInWithSso, signOut, + purgeAnalytics, refresh, setActiveOrganization, }), @@ -269,6 +311,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { signInWithGoogle, signInWithSso, signOut, + purgeAnalytics, refresh, setActiveOrganization, ], diff --git a/apps/mobile/src/auth/AuthProvider.test.tsx b/apps/mobile/src/auth/AuthProvider.test.tsx index 05f98ea89ee..9e68f42332c 100644 --- a/apps/mobile/src/auth/AuthProvider.test.tsx +++ b/apps/mobile/src/auth/AuthProvider.test.tsx @@ -41,10 +41,20 @@ const webBrowserMock = vi.hoisted(() => ({ openAuthSessionAsync: vi.fn(), })); +const analyticsMock = vi.hoisted(() => ({ + configure: vi.fn(() => Promise.resolve()), + flush: vi.fn(() => Promise.resolve()), + purge: vi.fn(() => Promise.resolve()), + track: vi.fn(() => Promise.resolve("event_1")), +})); + vi.mock("react-native", async () => { const React = await import("react"); return { + AppState: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, View: ({ children }: HostProps) => React.createElement("View", null, children), }; @@ -76,6 +86,13 @@ vi.mock("@/api/mobile", () => ({ createSessionRequestUrl: apiMock.createSessionRequestUrl, })); +vi.mock("@/analytics/product-analytics", () => ({ + configureMobileProductAnalytics: analyticsMock.configure, + flushMobileProductAnalytics: analyticsMock.flush, + purgeMobileProductAnalytics: analyticsMock.purge, + trackMobileProductEvent: analyticsMock.track, +})); + ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -130,6 +147,10 @@ describe("AuthProvider", () => { }); apiMock.createSessionRequestUrl.mockClear(); webBrowserMock.openAuthSessionAsync.mockReset(); + analyticsMock.configure.mockClear(); + analyticsMock.flush.mockClear(); + analyticsMock.purge.mockClear(); + analyticsMock.track.mockClear(); }); it("stores a mobile session returned by Sign in with Apple", async () => { @@ -177,6 +198,7 @@ describe("AuthProvider", () => { status: "signedIn", userId: "apple_user", }); + expect(analyticsMock.track).toHaveBeenCalledWith("user_signed_in"); }); it("leaves the user signed out when Apple authentication is cancelled", async () => { diff --git a/apps/mobile/src/screens/delete-account.test.tsx b/apps/mobile/src/screens/delete-account.test.tsx index af148bca914..73708e52dba 100644 --- a/apps/mobile/src/screens/delete-account.test.tsx +++ b/apps/mobile/src/screens/delete-account.test.tsx @@ -22,6 +22,7 @@ const auth = vi.hoisted(() => ({ Promise.resolve({ success: true as const }), ), }, + purgeAnalytics: vi.fn(() => Promise.resolve()), signOut: vi.fn(() => Promise.resolve()), }, })); @@ -225,6 +226,7 @@ describe("DeleteAccountScreen", () => { }); expect(auth.value.client.requestAccountDeletion).toHaveBeenCalledTimes(1); + expect(auth.value.purgeAnalytics).toHaveBeenCalledTimes(1); expect(auth.value.signOut).toHaveBeenCalledTimes(1); expect(router.replace).toHaveBeenCalledWith("/(tabs)/account"); const { Alert } = await import("react-native"); diff --git a/apps/mobile/src/screens/record.test.tsx b/apps/mobile/src/screens/record.test.tsx index 1a1bd00823c..21ebffbdb89 100644 --- a/apps/mobile/src/screens/record.test.tsx +++ b/apps/mobile/src/screens/record.test.tsx @@ -78,6 +78,12 @@ const uploadState = vi.hoisted(() => ({ queue: { jobs: [] }, })); +vi.mock("@/analytics/product-analytics", () => ({ + classifyMobileAnalyticsFailure: vi.fn(() => "unknown"), + createMobileProductAnalyticsEventId: vi.fn(() => "attempt_1"), + trackMobileProductEventWithId: vi.fn(() => Promise.resolve("event_1")), +})); + ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/apps/mobile/src/uploads/recording-upload-provider.test.tsx b/apps/mobile/src/uploads/recording-upload-provider.test.tsx index 5150003d02e..c9246c9aeed 100644 --- a/apps/mobile/src/uploads/recording-upload-provider.test.tsx +++ b/apps/mobile/src/uploads/recording-upload-provider.test.tsx @@ -29,6 +29,10 @@ const screenRecordingState = vi.hoisted(() => ({ getUpdates: vi.fn(), })); +const analyticsState = vi.hoisted(() => ({ + track: vi.fn(() => Promise.resolve("event_1")), +})); + const authState = vi.hoisted(() => ({ createRecording: vi.fn(), createRecordingUploadTargets: vi.fn(), @@ -46,6 +50,10 @@ vi.mock("react-native", () => ({ }, })); vi.mock("@/api/mobile", () => ({ uploadToTarget })); +vi.mock("@/analytics/product-analytics", () => ({ + classifyMobileAnalyticsFailure: vi.fn(() => "unknown"), + trackMobileProductEventWithId: analyticsState.track, +})); vi.mock("../../modules/cap-screen-recorder", () => ({ cancelScreenRecording: screenRecordingState.cancel, getScreenRecordingUpdates: screenRecordingState.getUpdates, @@ -108,6 +116,8 @@ describe("RecordingUploadProvider", () => { uploadToTarget.mockClear(); screenRecordingState.cancel.mockClear(); screenRecordingState.getUpdates.mockReset(); + analyticsState.track.mockReset(); + analyticsState.track.mockResolvedValue("event_1"); authState.createRecording.mockReset(); authState.createRecording.mockResolvedValue({ id: "cap_123", diff --git a/apps/mobile/src/uploads/recording-upload-provider.tsx b/apps/mobile/src/uploads/recording-upload-provider.tsx index a2f10f569c3..cb13d38a4e8 100644 --- a/apps/mobile/src/uploads/recording-upload-provider.tsx +++ b/apps/mobile/src/uploads/recording-upload-provider.tsx @@ -10,10 +10,15 @@ import { useState, } from "react"; import { AppState } from "react-native"; +import { + classifyMobileAnalyticsFailure, + trackMobileProductEventWithId, +} from "@/analytics/product-analytics"; import { uploadToTarget } from "@/api/mobile"; import { useAuth } from "@/auth/AuthContext"; import type { CapRecorderSegmentEvent } from "../../modules/cap-recorder"; import { + type CapScreenRecorderUpdates, cancelScreenRecording, getScreenRecordingUpdates, } from "../../modules/cap-screen-recorder"; @@ -98,6 +103,49 @@ const errorMessage = (error: unknown) => ? error.message : "The recording upload was interrupted."; +const trackExternalRecordingTerminal = async ( + job: RecordingUploadJob, + updates: CapScreenRecorderUpdates, +) => { + if ( + !["finished", "cancelled", "failed", "missing"].includes(updates.status) + ) { + return; + } + const durationSeconds = Math.max(0, updates.durationSeconds ?? 0); + const occurredAt = new Date( + Date.parse(job.createdAt) + durationSeconds * 1000, + ).toISOString(); + await trackMobileProductEventWithId( + `mobile:recording:${job.id}:completed`, + occurredAt, + "recording_completed", + { + mode: "screen", + status: + updates.status === "cancelled" + ? "cancelled" + : updates.status === "failed" || updates.status === "missing" + ? "failed" + : "success", + duration_secs: durationSeconds, + segment_count: 0, + track_failure_count: + updates.status === "failed" || updates.status === "missing" ? 1 : 0, + ...(updates.status === "failed" || updates.status === "missing" + ? { + error_class: + updates.status === "missing" + ? "missing" + : classifyMobileAnalyticsFailure( + updates.error ? new Error(updates.error) : null, + ), + } + : {}), + }, + ).catch(() => undefined); +}; + const displayQueueFrom = ( queue: RecordingUploadQueue, current: RecordingUploadQueue = emptyRecordingUploadQueue, @@ -123,6 +171,7 @@ const reconcileExternalRecordings = async (queue: RecordingUploadQueue) => { if (job.uploadOwner !== "external") continue; try { const updates = await getScreenRecordingUpdates(job.id); + await trackExternalRecordingTerminal(job, updates); if (updates.status === "uploaded") { nextQueue = recordingUploadQueueReducer(nextQueue, { type: "externalProcessing", @@ -401,6 +450,7 @@ export function RecordingUploadProvider({ children }: { children: ReactNode }) { if (!job || job.uploadOwner !== "external") continue; const updates = result.updates; if (!updates) continue; + await trackExternalRecordingTerminal(job, updates); if (updates.status === "cancelled") { removedJobs.current.add(job.id); apply({ type: "remove", id: job.id }); diff --git a/apps/mobile/src/uploads/runMobileUpload.test.ts b/apps/mobile/src/uploads/runMobileUpload.test.ts index d06b910ba9c..b5ae366a8b6 100644 --- a/apps/mobile/src/uploads/runMobileUpload.test.ts +++ b/apps/mobile/src/uploads/runMobileUpload.test.ts @@ -1,5 +1,5 @@ import { Video } from "@cap/web-domain"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MobileApiClient, UploadFile } from "@/api/mobile"; import { runMobileUpload } from "./runMobileUpload"; @@ -15,11 +15,24 @@ const uploadMock = vi.hoisted(() => ({ ), })); +const analyticsMock = vi.hoisted(() => ({ + track: vi.fn(() => Promise.resolve("event_1")), +})); + vi.mock("@/api/mobile", () => ({ uploadToTarget: uploadMock.uploadToTarget, })); +vi.mock("@/analytics/product-analytics", () => ({ + classifyMobileAnalyticsFailure: vi.fn(() => "network"), + trackMobileProductEventWithId: analyticsMock.track, +})); + describe("runMobileUpload", () => { + beforeEach(() => { + analyticsMock.track.mockClear(); + }); + it("passes native video metadata through upload creation and retry-safe progress", async () => { const createUpload = vi.fn(async () => ({ id: Video.VideoId.make("video_123"), @@ -80,6 +93,12 @@ describe("runMobileUpload", () => { contentLength: 80, }); expect(onProgress).toHaveBeenCalledWith(0.5); + expect(analyticsMock.track).toHaveBeenCalledWith( + "mobile:upload:video_123:completed", + expect.any(String), + "multipart_upload_complete", + expect.objectContaining({ length: 12.5, size: 80 }), + ); }); it("normalizes non-finite native upload progress", async () => { @@ -200,4 +219,37 @@ describe("runMobileUpload", () => { total: 100, }); }); + + it("does not turn a completed upload into a user-visible failure when analytics persistence fails", async () => { + analyticsMock.track.mockRejectedValueOnce(new Error("disk unavailable")); + const created = { + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + rawFileKey: "user_123/video_123/raw-upload.mov", + upload: { + type: "put" as const, + url: "https://uploads.example/video", + headers: { "Content-Type": "video/quicktime" }, + }, + }; + const completeUpload = vi.fn(async () => ({ success: true as const })); + const client = { + createUpload: vi.fn(async () => created), + updateUploadProgress: vi.fn(async () => ({ success: true as const })), + completeUpload, + } as unknown as MobileApiClient; + const file: UploadFile = { + uri: "file:///tmp/video.mov", + name: "video.mov", + type: "video/quicktime", + size: 80, + durationSeconds: 12.5, + width: 1920, + height: 1080, + }; + + await expect(runMobileUpload({ client, file })).resolves.toBe(created); + expect(completeUpload).toHaveBeenCalledTimes(1); + expect(analyticsMock.track).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/mobile/src/uploads/runMobileUpload.ts b/apps/mobile/src/uploads/runMobileUpload.ts index 88275516736..775c26c49f9 100644 --- a/apps/mobile/src/uploads/runMobileUpload.ts +++ b/apps/mobile/src/uploads/runMobileUpload.ts @@ -1,3 +1,7 @@ +import { + classifyMobileAnalyticsFailure, + trackMobileProductEventWithId, +} from "@/analytics/product-analytics"; import type { MobileApiClient, UploadFile } from "@/api/mobile"; import { uploadToTarget } from "@/api/mobile"; @@ -47,85 +51,118 @@ export const runMobileUpload = async ({ onCreated, onProgress, }: RunMobileUploadInput) => { - const created = await client.createUpload({ - organizationId: organizationId ?? undefined, - folderId: folderId ?? undefined, - fileName: file.name, - contentType: file.type, - contentLength: file.size, - durationSeconds: file.durationSeconds, - width: file.width, - height: file.height, - }); - onCreated?.(created.id, created.rawFileKey); + const startedAt = Date.now(); + let createdId: string | null = null; + try { + const created = await client.createUpload({ + organizationId: organizationId ?? undefined, + folderId: folderId ?? undefined, + fileName: file.name, + contentType: file.type, + contentLength: file.size, + durationSeconds: file.durationSeconds, + width: file.width, + height: file.height, + }); + createdId = created.id; + onCreated?.(created.id, created.rawFileKey); - let lastSyncedProgress: number | null = null; - let lastSyncedAt = 0; - let lastUiPercent = -1; - let lastUiAt = 0; - let pendingProgress: { uploaded: number; total: number } | null = null; - let progressSync: Promise | null = null; - const syncProgress = async (initial: { uploaded: number; total: number }) => { - let next: { uploaded: number; total: number } | null = initial; - while (next) { - try { - await client.updateUploadProgress(created.id, next); - } catch {} - next = pendingProgress; - pendingProgress = null; - } - progressSync = null; - }; - const enqueueProgressSync = (progress: { - uploaded: number; - total: number; - }) => { - if (progressSync) { - pendingProgress = progress; - return; - } - progressSync = syncProgress(progress); - }; + let lastSyncedProgress: number | null = null; + let lastSyncedAt = 0; + let lastUiPercent = -1; + let lastUiAt = 0; + let pendingProgress: { uploaded: number; total: number } | null = null; + let progressSync: Promise | null = null; + const syncProgress = async (initial: { + uploaded: number; + total: number; + }) => { + let next: { uploaded: number; total: number } | null = initial; + while (next) { + try { + await client.updateUploadProgress(created.id, next); + } catch {} + next = pendingProgress; + pendingProgress = null; + } + progressSync = null; + }; + const enqueueProgressSync = (progress: { + uploaded: number; + total: number; + }) => { + if (progressSync) { + pendingProgress = progress; + return; + } + progressSync = syncProgress(progress); + }; - await uploadToTarget(created.upload, file, ({ loaded, total }) => { - const safeLoaded = nonNegativeFiniteNumber(loaded); - const safeTotal = - positiveFiniteNumber(total) ?? - positiveFiniteNumber(file.size) ?? - safeLoaded; - const progress = safeTotal > 0 ? safeLoaded / safeTotal : 0; - const clampedProgress = clampProgress(progress); - const now = Date.now(); - const uiPercent = Math.floor(clampedProgress * 100); - if ( - lastUiPercent < 0 || - uiPercent >= 100 || - uiPercent - lastUiPercent >= uploadProgressUiMinPercentDelta || - now - lastUiAt >= uploadProgressUiIntervalMs - ) { - lastUiPercent = uiPercent; - lastUiAt = now; - onProgress?.(clampedProgress); - } - if ( - shouldSyncUploadProgress( - clampedProgress, - now, - lastSyncedProgress, - lastSyncedAt, - ) - ) { - lastSyncedProgress = clampedProgress; - lastSyncedAt = now; - enqueueProgressSync({ uploaded: safeLoaded, total: safeTotal }); - } - }); - await progressSync; + await uploadToTarget(created.upload, file, ({ loaded, total }) => { + const safeLoaded = nonNegativeFiniteNumber(loaded); + const safeTotal = + positiveFiniteNumber(total) ?? + positiveFiniteNumber(file.size) ?? + safeLoaded; + const progress = safeTotal > 0 ? safeLoaded / safeTotal : 0; + const clampedProgress = clampProgress(progress); + const now = Date.now(); + const uiPercent = Math.floor(clampedProgress * 100); + if ( + lastUiPercent < 0 || + uiPercent >= 100 || + uiPercent - lastUiPercent >= uploadProgressUiMinPercentDelta || + now - lastUiAt >= uploadProgressUiIntervalMs + ) { + lastUiPercent = uiPercent; + lastUiAt = now; + onProgress?.(clampedProgress); + } + if ( + shouldSyncUploadProgress( + clampedProgress, + now, + lastSyncedProgress, + lastSyncedAt, + ) + ) { + lastSyncedProgress = clampedProgress; + lastSyncedAt = now; + enqueueProgressSync({ uploaded: safeLoaded, total: safeTotal }); + } + }); + await progressSync; - await client.completeUpload(created.id, { - rawFileKey: created.rawFileKey, - contentLength: file.size, - }); + await client.completeUpload(created.id, { + rawFileKey: created.rawFileKey, + contentLength: file.size, + }); + const completedAt = Date.now(); + await trackMobileProductEventWithId( + `mobile:upload:${created.id}:completed`, + new Date(completedAt).toISOString(), + "multipart_upload_complete", + { + duration: Math.max(0, (completedAt - startedAt) / 1000), + length: nonNegativeFiniteNumber(file.durationSeconds), + size: nonNegativeFiniteNumber(file.size), + }, + ).catch(() => undefined); - return created; + return created; + } catch (error) { + const failedAt = Date.now(); + await trackMobileProductEventWithId( + createdId + ? `mobile:upload:${createdId}:failed` + : `mobile:upload-attempt:${startedAt}:failed`, + new Date(failedAt).toISOString(), + "multipart_upload_failed", + { + duration: Math.max(0, (failedAt - startedAt) / 1000), + failure_class: classifyMobileAnalyticsFailure(error), + }, + ).catch(() => undefined); + throw error; + } }; diff --git a/apps/web/__tests__/e2e/product-analytics-mysql-e2e.test.ts b/apps/web/__tests__/e2e/product-analytics-mysql-e2e.test.ts new file mode 100644 index 00000000000..36344351a2a --- /dev/null +++ b/apps/web/__tests__/e2e/product-analytics-mysql-e2e.test.ts @@ -0,0 +1,719 @@ +import type { RowDataPacket } from "mysql2"; +import mysql, { type Connection } from "mysql2/promise"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("workflow/api", () => ({ + start: vi.fn(async () => ({ runId: "analytics-mysql-e2e" })), +})); + +const inviteMocks = vi.hoisted(() => ({ + environment: { + PRODUCT_ANALYTICS_TINYBIRD_HOST: "https://staging.tinybird.test", + PRODUCT_ANALYTICS_TINYBIRD_TOKEN: "staging-ingest-token", + RESEND_API_KEY: "staging-resend-key", + WEB_URL: "https://staging.cap.test", + }, + sendEmail: vi.fn(), +})); + +vi.mock("@cap/database/emails/config", () => ({ + sendEmail: inviteMocks.sendEmail, +})); +vi.mock("@cap/database/emails/organization-invite", () => ({ + OrganizationInvite: vi.fn(() => null), +})); +vi.mock("@cap/env", async (importOriginal) => ({ + ...(await importOriginal()), + serverEnv: () => inviteMocks.environment, +})); + +const enabled = process.env.CAP_PRODUCT_ANALYTICS_MYSQL_E2E === "1"; +const analyticsMysqlE2e = enabled ? describe.sequential : describe.skip; +const databaseName = + process.env.CAP_PRODUCT_ANALYTICS_MYSQL_DATABASE ?? + `cap_product_analytics_e2e_${process.pid}`; +const adminUrl = + process.env.CAP_PRODUCT_ANALYTICS_MYSQL_ADMIN_URL ?? + "mysql://root@127.0.0.1:3306"; +const databaseUrl = `${adminUrl}/${databaseName}`; + +let admin: Connection; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const runRepo = async ( + operation: ( + repo: import("@cap/web-backend").ProductAnalyticsErasureLeaseStore, + ) => import("effect").Effect.Effect, +) => { + const { Database, ProductAnalyticsErasureLeaseRepo } = await import( + "@cap/web-backend" + ); + const { Effect } = await import("effect"); + return Effect.runPromise( + Effect.gen(function* () { + const repo = yield* ProductAnalyticsErasureLeaseRepo; + return yield* operation(repo); + }).pipe( + Effect.provide(ProductAnalyticsErasureLeaseRepo.Default), + Effect.provide(Database.Default), + ), + ); +}; + +const appendCollectorRows = async ( + rows: readonly import("@cap/analytics").ProductEventRow[], +) => { + const { Database, ProductAnalytics } = await import("@cap/web-backend"); + const { Effect } = await import("effect"); + return Effect.runPromise( + Effect.gen(function* () { + const analytics = yield* ProductAnalytics; + return yield* analytics.appendWithIdentityFence(rows); + }).pipe( + Effect.provide(ProductAnalytics.Default), + Effect.provide(Database.Default), + ), + ); +}; + +const appendCollectorRowsEither = async ( + rows: readonly import("@cap/analytics").ProductEventRow[], +) => { + const { Database, ProductAnalytics } = await import("@cap/web-backend"); + const { Effect } = await import("effect"); + return Effect.runPromise( + Effect.gen(function* () { + const analytics = yield* ProductAnalytics; + return yield* analytics.appendWithIdentityFence(rows); + }).pipe( + Effect.either, + Effect.provide(ProductAnalytics.Default), + Effect.provide(Database.Default), + ), + ); +}; + +beforeAll(async () => { + if (!enabled) return; + const parsed = new URL(adminUrl); + if ( + parsed.hostname !== "127.0.0.1" || + parsed.port !== "3306" || + !/^[a-z0-9_]+$/.test(databaseName) || + !databaseName.startsWith("cap_product_analytics_e2e_") + ) { + throw new Error("Analytics MySQL E2E requires an isolated local database"); + } + admin = await mysql.createConnection(adminUrl); + await admin.query(`CREATE DATABASE \`${databaseName}\``); + process.env.DATABASE_URL = databaseUrl; + const connection = await mysql.createConnection(databaseUrl); + await connection.query(` + CREATE TABLE product_analytics_erasure_leases ( + name varchar(64) NOT NULL PRIMARY KEY, + ownerId varchar(64), requestId varchar(64), + fencingToken bigint unsigned NOT NULL DEFAULT 0, + leaseExpiresAt timestamp NULL, phase varchar(32) NOT NULL DEFAULT 'idle', + pausedPipes json, userId varchar(255), organizationId varchar(255), + attemptCount int NOT NULL DEFAULT 0, lastErrorCode varchar(64), + createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + `); + await connection.query(` + CREATE TABLE product_analytics_identity_state ( + identityHash varchar(64) NOT NULL PRIMARY KEY, + identityKind varchar(16) NOT NULL, blockedAt timestamp NULL, + createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + `); + await connection.query(` + CREATE TABLE product_analytics_identity_links ( + anonymousIdentityHash varchar(64) NOT NULL, + userIdentityHash varchar(64) NOT NULL, + organizationIdentityHash varchar(64), anonymousId varchar(255) NOT NULL, + createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (anonymousIdentityHash, userIdentityHash) + ) + `); + await connection.query(` + CREATE TABLE product_analytics_event_receipts ( + eventIdHash varchar(64) NOT NULL PRIMARY KEY, payloadHash varchar(32) NOT NULL, + anonymousIdentityHash varchar(64), userIdentityHash varchar(64), + organizationIdentityHash varchar(64), conflictCount int NOT NULL DEFAULT 0, + firstSeenAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + lastSeenAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + retainUntil timestamp NOT NULL + ) + `); + await connection.query(` + CREATE TABLE product_analytics_outbox ( + eventId varchar(128) NOT NULL PRIMARY KEY, deliveryKey varchar(36) NOT NULL UNIQUE, + payloadHash varchar(32) NOT NULL, eventName varchar(64) NOT NULL, + payloadKind varchar(32) NOT NULL DEFAULT 'product_event_row_v1', payload json NOT NULL, + anonymousId varchar(255), userId varchar(255), organizationId varchar(255), + status varchar(32) NOT NULL DEFAULT 'pending', attemptCount int NOT NULL DEFAULT 0, + nextAttemptAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, leaseOwnerId varchar(64), + leaseExpiresAt timestamp NULL, workflowRunId varchar(128), + payloadConflict boolean NOT NULL DEFAULT false, lastErrorCode varchar(64), + deliveredAt timestamp NULL, deadLetteredAt timestamp NULL, + createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + `); + await connection.query(` + CREATE TABLE product_analytics_ingestion_leases ( + id varchar(36) NOT NULL PRIMARY KEY, fencingToken bigint unsigned NOT NULL, + expiresAt timestamp NOT NULL, + createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + await connection.query(` + CREATE TABLE product_analytics_refresh_leases ( + name varchar(64) NOT NULL PRIMARY KEY, ownerId varchar(36), + generation bigint unsigned NOT NULL DEFAULT 0, sourceCutoff timestamp NULL, + leaseExpiresAt timestamp NULL, status varchar(32) NOT NULL DEFAULT 'idle', + lastCompletedAt timestamp NULL, lastErrorCode varchar(64), + createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY expiry_idx (leaseExpiresAt), KEY status_idx (status) + ) + `); + await connection.query(` + CREATE TABLE product_analytics_reconciliation_failures ( + sourceHash varchar(64) NOT NULL PRIMARY KEY, sourceType varchar(32) NOT NULL, + errorCode varchar(64) NOT NULL, attemptCount int NOT NULL DEFAULT 1, + firstSeenAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + lastSeenAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY source_type_idx (sourceType, lastSeenAt) + ) + `); + await connection.query(` + CREATE TABLE product_analytics_erasure_requests ( + id varchar(36) NOT NULL PRIMARY KEY, scopeHash varchar(64) NOT NULL UNIQUE, + userId varchar(255), organizationId varchar(255), + status varchar(32) NOT NULL DEFAULT 'pending', attemptCount int NOT NULL DEFAULT 0, + nextAttemptAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, leaseOwnerId varchar(36), + leaseExpiresAt timestamp NULL, lastErrorCode varchar(64), + createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + `); + await connection.query(` + CREATE TABLE organizations ( + id varchar(21) NOT NULL PRIMARY KEY, name varchar(255) NOT NULL, + tombstoneAt timestamp NULL + ) + `); + await connection.query(` + CREATE TABLE organization_invites ( + id varchar(21) NOT NULL PRIMARY KEY, organizationId varchar(21) NOT NULL, + invitedEmail varchar(255) NOT NULL, invitedEmailNormalized varchar(255), + invitedByUserId varchar(21) NOT NULL, role varchar(255) NOT NULL, + status varchar(255) NOT NULL DEFAULT 'pending', + emailDeliveryState varchar(32) NOT NULL DEFAULT 'legacy', + emailDeliveryAttemptCount int NOT NULL DEFAULT 0, + emailDeliveryNextAttemptAt timestamp NULL, emailDeliveryErrorCode varchar(64), + emailDeliveryLeaseOwnerId varchar(36), emailDeliveryLeaseExpiresAt timestamp NULL, + emailProviderMessageId varchar(255), emailSentAt timestamp NULL, + createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + expiresAt timestamp NULL, + UNIQUE KEY normalized_email_idx (organizationId, invitedEmailNormalized) + ) + `); + await connection.end(); +}); + +afterAll(async () => { + if (!enabled || !admin) return; + if (!databaseName.startsWith("cap_product_analytics_e2e_")) { + throw new Error("Refusing to drop an unscoped database"); + } + await admin.query(`DROP DATABASE \`${databaseName}\``); + await admin.end(); +}); + +analyticsMysqlE2e("product analytics MySQL concurrency", () => { + it("reuses the global lease and increments its fencing token", async () => { + const first = await runRepo((repo) => repo.claimNew({ userId: "user-a" })); + expect(first).not.toBeNull(); + if (!first) return; + expect(await runRepo((repo) => repo.complete(first))).toBe(true); + const second = await runRepo((repo) => repo.claimNew({ userId: "user-b" })); + expect(second?.requestId).not.toBe(first.requestId); + expect(second?.fencingToken).toBe(first.fencingToken + 1); + if (second) + expect(await runRepo((repo) => repo.complete(second))).toBe(true); + }); + + it("allows exactly one concurrent owner", async () => { + const claims = await Promise.all( + Array.from({ length: 8 }, (_, index) => + runRepo((repo) => repo.claimNew({ userId: `concurrent-${index}` })), + ), + ); + const winners = claims.filter((claim) => claim !== null); + expect(winners).toHaveLength(1); + const winner = winners[0]; + if (winner) { + expect(await runRepo((repo) => repo.complete(winner))).toBe(true); + } + }); + + it("admits concurrent ingestion without owning the erasure row lock", async () => { + const { + acquireProductAnalyticsIngestionLease, + releaseProductAnalyticsIngestionLease, + } = await import("@/lib/analytics/product-event-outbox-state"); + const leases = await Promise.all( + Array.from({ length: 32 }, () => acquireProductAnalyticsIngestionLease()), + ); + expect(leases.every((lease) => typeof lease === "string")).toBe(true); + expect(new Set(leases).size).toBe(32); + await Promise.all( + leases.flatMap((lease) => + lease ? [releaseProductAnalyticsIngestionLease(lease)] : [], + ), + ); + }); + + it("allows one refresh owner and fences refresh against erasure", async () => { + const { + acquireProductAnalyticsRefreshLease, + releaseProductAnalyticsRefreshLease, + } = await import("@/lib/analytics/product-analytics-refresh-state"); + const cutoff = new Date("2026-07-31T12:00:00.000Z"); + const claims = await Promise.all( + Array.from({ length: 8 }, () => + acquireProductAnalyticsRefreshLease(cutoff), + ), + ); + const winners = claims.filter((claim): claim is NonNullable => + Boolean(claim), + ); + expect(winners).toHaveLength(1); + expect(winners[0]?.sourceCutoff).toBe(cutoff.toISOString()); + if (!winners[0]) return; + await releaseProductAnalyticsRefreshLease(winners[0].ownerId); + + const verifier = await mysql.createConnection(databaseUrl); + await verifier.query( + "UPDATE product_analytics_erasure_leases SET phase = 'claimed' WHERE name = 'global'", + ); + await expect( + acquireProductAnalyticsRefreshLease(cutoff), + ).resolves.toBeUndefined(); + await verifier.query( + "UPDATE product_analytics_erasure_leases SET phase = 'idle' WHERE name = 'global'", + ); + const [refreshRows] = await verifier.query< + Array< + { + generation: number; + ownerId: string | null; + status: string; + } & RowDataPacket + > + >( + "SELECT ownerId, generation, status FROM product_analytics_refresh_leases WHERE name = 'global'", + ); + const [ingestionRows] = await verifier.query>( + "SELECT id FROM product_analytics_ingestion_leases", + ); + await verifier.end(); + expect(refreshRows[0]).toMatchObject({ + generation: 1, + ownerId: null, + status: "idle", + }); + expect(ingestionRows).toHaveLength(0); + }); + + it("isolates payload conflicts without poisoning valid batch neighbors", async () => { + const { createProductEventRows, productAnalyticsEventIdHash } = + await import("@cap/analytics"); + const appendRequests: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + appendRequests.push(String(init?.body ?? "")); + return new Response(null, { status: 202 }); + }), + ); + const [original] = createProductEventRows( + [ + { + anonymousId: "collector-anonymous", + eventId: "collector-retry-alpha", + eventName: "page_view", + occurredAt: "2026-07-31T12:00:00.000Z", + platform: "web", + properties: { + hostname: "cap.test", + is_session_entry: true, + session_started_at: "2026-07-31T12:00:00.000Z", + }, + }, + ], + { + hostname: "cap.test", + receivedAt: "2026-07-31T12:00:01.000Z", + source: "client", + }, + ); + if (!original) throw new Error("Expected a collector fixture row"); + await Promise.all([ + appendCollectorRows([original]), + appendCollectorRows([original]), + ]); + expect(appendRequests).toHaveLength(2); + + const conflicting = { + ...original, + pathname: "/different", + payload_hash: "f".repeat(32), + }; + const validNeighbor = { + ...original, + event_id: `${original.event_id}_neighbor`, + payload_hash: "e".repeat(32), + }; + const admission = await appendCollectorRowsEither([ + conflicting, + validNeighbor, + ]); + expect(admission).toMatchObject({ + _tag: "Right", + right: { + acceptedEventIds: [validNeighbor.event_id], + rejectedEventIds: [original.event_id], + }, + }); + expect(appendRequests).toHaveLength(3); + expect(appendRequests[2]).toContain(validNeighbor.event_id); + expect(appendRequests[2]).not.toContain(conflicting.payload_hash); + const verifier = await mysql.createConnection(databaseUrl); + const [receipts] = await verifier.query< + Array< + { + conflictCount: number; + payloadHash: string; + } & RowDataPacket + > + >( + "SELECT payloadHash, conflictCount FROM product_analytics_event_receipts WHERE eventIdHash = ?", + [productAnalyticsEventIdHash(original.event_id)], + ); + expect(receipts[0]).toMatchObject({ + conflictCount: 1, + payloadHash: original.payload_hash, + }); + await verifier.query( + "DELETE FROM product_analytics_event_receipts WHERE eventIdHash IN (?, ?)", + [ + productAnalyticsEventIdHash(original.event_id), + productAnalyticsEventIdHash(validNeighbor.event_id), + ], + ); + await verifier.end(); + }); + + it("keeps untrusted client IDs disjoint from authoritative server IDs", async () => { + const { createProductEventRows, productAnalyticsEventIdHash } = + await import("@cap/analytics"); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 202 })), + ); + const rawEventId = "share_link_created:known-video-id"; + const [clientRow] = createProductEventRows( + [ + { + anonymousId: "preclaim-anonymous", + eventId: rawEventId, + eventName: "page_view", + occurredAt: "2026-07-31T12:00:00.000Z", + platform: "web", + properties: { + hostname: "cap.test", + is_session_entry: true, + session_started_at: "2026-07-31T12:00:00.000Z", + }, + }, + ], + { + hostname: "cap.test", + receivedAt: "2026-07-31T12:00:01.000Z", + source: "client", + }, + ); + const [serverRow] = createProductEventRows( + [ + { + anonymousId: "user:preclaim-user", + eventId: rawEventId, + eventName: "share_link_created", + occurredAt: "2026-07-31T12:00:02.000Z", + platform: "server", + properties: { + asset_type: "recording", + recording_mode: "instant", + }, + }, + ], + { + organizationId: "preclaim-org", + receivedAt: "2026-07-31T12:00:03.000Z", + source: "server", + userId: "preclaim-user", + }, + ); + if (!clientRow || !serverRow) throw new Error("Expected preclaim fixtures"); + expect(clientRow.event_id).not.toBe(serverRow.event_id); + const clientAdmission = await appendCollectorRows([clientRow]); + const serverAdmission = await appendCollectorRows([serverRow]); + expect(clientAdmission.acceptedEventIds).toEqual([clientRow.event_id]); + expect(serverAdmission.acceptedEventIds).toEqual([serverRow.event_id]); + + const verifier = await mysql.createConnection(databaseUrl); + await verifier.query( + "DELETE FROM product_analytics_event_receipts WHERE eventIdHash IN (?, ?)", + [ + productAnalyticsEventIdHash(clientRow.event_id), + productAnalyticsEventIdHash(serverRow.event_id), + ], + ); + await verifier.end(); + }); + + it("preserves another user's event when an anonymous cookie is shared", async () => { + const { db } = await import("@cap/database"); + const { persistProductAnalyticsEvent } = await import( + "@/lib/analytics/product-event-outbox" + ); + const anonymousId = "shared-cookie-alpha"; + for (const userId of ["shared-user-a", "shared-user-b"]) { + await db().transaction((tx) => + persistProductAnalyticsEvent(tx, { + eventId: `${userId}:signup`, + eventName: "user_signed_up", + occurredAt: new Date().toISOString(), + anonymousId, + platform: "web", + userId, + organizationId: `${userId}:org`, + }), + ); + } + const aliases = await runRepo((repo) => + repo.discardPendingEvents({ userId: "shared-user-a" }, [anonymousId]), + ); + expect(aliases).toEqual([]); + const verifier = await mysql.createConnection(databaseUrl); + const [remaining] = await verifier.query< + Array<{ userId: string } & RowDataPacket> + >("SELECT userId FROM product_analytics_outbox"); + await verifier.end(); + expect(remaining.map(({ userId }) => userId)).toEqual(["shared-user-b"]); + }); + + it("does not spread a deleted user's tombstone to a live organization", async () => { + const { createProductEventRows } = await import("@cap/analytics"); + const { db } = await import("@cap/database"); + const { productAnalyticsIdentityHash } = await import("@cap/web-backend"); + const { persistProductAnalyticsEvent } = await import( + "@/lib/analytics/product-event-outbox" + ); + await runRepo((repo) => + repo.discardPendingEvents({ userId: "deleted-user" }), + ); + const suppressed = await db().transaction((tx) => + persistProductAnalyticsEvent(tx, { + eventId: "deleted-user:late", + eventName: "user_signed_up", + occurredAt: new Date().toISOString(), + anonymousId: "deleted-user:new-alias", + platform: "web", + userId: "deleted-user", + organizationId: "live-organization", + }), + ); + expect(suppressed.status).toBe("suppressed"); + const [blockedCollectorRow] = createProductEventRows( + [ + { + anonymousId: "deleted-user:new-alias", + eventId: "deleted-user:late-client-event", + eventName: "page_view", + occurredAt: "2026-07-31T12:00:00.000Z", + platform: "web", + properties: { + hostname: "cap.test", + is_session_entry: true, + session_started_at: "2026-07-31T12:00:00.000Z", + }, + }, + ], + { + hostname: "cap.test", + receivedAt: "2026-07-31T12:00:01.000Z", + source: "client", + userId: "deleted-user", + }, + ); + if (!blockedCollectorRow) + throw new Error("Expected a blocked collector row"); + await expect(appendCollectorRows([blockedCollectorRow])).resolves.toEqual({ + acceptedEventIds: [], + rejectedEventIds: [blockedCollectorRow.event_id], + }); + const accepted = await db().transaction((tx) => + persistProductAnalyticsEvent(tx, { + eventId: "live-user:signup", + eventName: "user_signed_up", + occurredAt: new Date().toISOString(), + anonymousId: "live-user:alias", + platform: "web", + userId: "live-user", + organizationId: "live-organization", + }), + ); + expect(accepted.status).not.toBe("suppressed"); + const verifier = await mysql.createConnection(databaseUrl); + const [organizationState] = await verifier.query< + Array<{ blockedAt: Date | null } & RowDataPacket> + >( + "SELECT blockedAt FROM product_analytics_identity_state WHERE identityHash = ?", + [productAnalyticsIdentityHash("organization", "live-organization")], + ); + await verifier.end(); + expect(organizationState[0]?.blockedAt).toBeNull(); + }); + + it("keeps first-payload ownership after delivery payload cleanup", async () => { + const { db } = await import("@cap/database"); + const { getProductAnalyticsOutboxHealth, persistProductAnalyticsEvent } = + await import("@/lib/analytics/product-event-outbox"); + const eventId = "receipt-first-write-alpha"; + const first = await db().transaction((tx) => + persistProductAnalyticsEvent(tx, { + eventId, + eventName: "user_signed_up", + occurredAt: "2026-07-01T12:00:00.000Z", + anonymousId: "receipt-alias", + platform: "web", + userId: "receipt-user", + organizationId: "receipt-org", + }), + ); + const verifier = await mysql.createConnection(databaseUrl); + await verifier.query( + "DELETE FROM product_analytics_outbox WHERE eventId = ?", + [eventId], + ); + await verifier.query( + "UPDATE product_analytics_event_receipts SET firstSeenAt = DATE_SUB(NOW(), INTERVAL 32 DAY), lastSeenAt = DATE_SUB(NOW(), INTERVAL 32 DAY)", + ); + await verifier.end(); + const replay = await db().transaction((tx) => + persistProductAnalyticsEvent(tx, { + eventId, + eventName: "user_signed_up", + occurredAt: "2026-07-01T12:00:01.000Z", + anonymousId: "receipt-alias", + platform: "web", + userId: "receipt-user", + organizationId: "receipt-org", + }), + ); + expect(first.payloadConflict).toBe(false); + expect(replay).toMatchObject({ payloadConflict: true, status: "conflict" }); + const health = await getProductAnalyticsOutboxHealth(); + expect(health.healthy).toBe(false); + expect(health.receiptPayloadConflictEvents).toBe(1); + expect(health.receiptPayloadConflictAttempts).toBe(1); + }); + + it("releases invite locks before provider I/O and fails closed without email configuration", async () => { + const verifier = await mysql.createConnection(databaseUrl); + await verifier.query( + "INSERT INTO organizations (id, name) VALUES ('orginvitealpha', 'Invite Org')", + ); + await verifier.query(` + INSERT INTO organization_invites ( + id, organizationId, invitedEmail, invitedEmailNormalized, + invitedByUserId, role, emailDeliveryState, emailDeliveryNextAttemptAt + ) VALUES ( + 'invitealpha', 'orginvitealpha', 'invitee@example.test', + 'invitee@example.test', 'inviteralpha', 'member', 'pending', NOW() + ) + `); + let releaseProvider: ((value: unknown) => void) | undefined; + inviteMocks.sendEmail.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseProvider = resolve; + }), + ); + const { deliverOrganizationInvite } = await import( + "@/lib/organization-invite-delivery" + ); + const delivery = deliverOrganizationInvite("invitealpha"); + while (!releaseProvider) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await verifier.beginTransaction(); + await verifier.query("SET SESSION innodb_lock_wait_timeout = 1"); + await verifier.query( + "SELECT id FROM organization_invites WHERE id = 'invitealpha' FOR UPDATE", + ); + await verifier.rollback(); + releaseProvider({ data: { id: "provider-message-alpha" }, error: null }); + await expect(delivery).resolves.toEqual({ status: "sent" }); + + await verifier.query(` + INSERT INTO organization_invites ( + id, organizationId, invitedEmail, invitedEmailNormalized, + invitedByUserId, role, emailDeliveryState, emailDeliveryNextAttemptAt + ) VALUES ( + 'invitebeta', 'orginvitealpha', 'second@example.test', + 'second@example.test', 'inviteralpha', 'member', 'pending', NOW() + ) + `); + inviteMocks.environment.RESEND_API_KEY = ""; + await expect(deliverOrganizationInvite("invitebeta")).resolves.toEqual({ + status: "deferred", + }); + const [deferred] = await verifier.query< + Array< + { + emailDeliveryState: string; + emailDeliveryErrorCode: string; + } & RowDataPacket + > + >( + "SELECT emailDeliveryState, emailDeliveryErrorCode FROM organization_invites WHERE id = 'invitebeta'", + ); + expect(deferred[0]).toMatchObject({ + emailDeliveryState: "pending", + emailDeliveryErrorCode: "provider_send_failed", + }); + inviteMocks.environment.RESEND_API_KEY = "staging-resend-key"; + await verifier.end(); + }); +}); diff --git a/apps/web/__tests__/unit/admin-product-analytics.test.ts b/apps/web/__tests__/unit/admin-product-analytics.test.ts new file mode 100644 index 00000000000..aedbed8b541 --- /dev/null +++ b/apps/web/__tests__/unit/admin-product-analytics.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { + AdminAnalyticsConfigurationError, + AdminAnalyticsRequestError, + assertAdminAnalyticsDateRange, + buildAdminAnalyticsEndpointUrl, + calculateHealthWindowStart, + decodeAnalyticsFreshnessResponse, + decodeExperimentOutcomesResponse, + decodeProductEventsResponse, + decodeTrafficAttributionResponse, + decodeTrafficOverviewResponse, + decodeTrafficTotalsResponse, + fetchOptionalRollbackEndpoint, +} from "@/app/admin/analytics/tinybird"; + +describe("admin analytics Tinybird client", () => { + it("builds an allowlisted endpoint query and omits unset filters", () => { + const url = buildAdminAnalyticsEndpointUrl( + "https://api.tinybird.co/", + "product_events_daily", + { + start_date: "2026-07-01", + end_date: "2026-07-31", + platform: "desktop", + app_version: undefined, + source: "", + limit: 1000, + }, + ); + + expect(url.origin).toBe("https://api.tinybird.co"); + expect(url.pathname).toBe("/v0/pipes/product_events_daily.json"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + start_date: "2026-07-01", + end_date: "2026-07-31", + platform: "desktop", + limit: "1000", + }); + }); + + it("rejects a malformed Tinybird host without including credentials", () => { + expect(() => + buildAdminAnalyticsEndpointUrl( + "not a URL", + "product_traffic_overview", + {}, + ), + ).toThrow(AdminAnalyticsConfigurationError); + }); + + it("caps the inclusive health query to less than 31 days", () => { + expect(calculateHealthWindowStart("2026-01-01", "2026-07-31")).toBe( + "2026-07-01", + ); + expect(calculateHealthWindowStart("2026-07-20", "2026-07-31")).toBe( + "2026-07-20", + ); + }); + + it("allows two year-over-year windows but rejects dates beyond retention", () => { + expect(() => + assertAdminAnalyticsDateRange("2024-06-01", "2026-07-31"), + ).not.toThrow(); + expect(() => + assertAdminAnalyticsDateRange("2024-05-01", "2026-07-31"), + ).toThrow("no longer than 800 UTC days"); + }); + + it("degrades only a missing rollback-era optional endpoint", async () => { + const missing = async () => { + throw new AdminAnalyticsRequestError("missing", 404); + }; + await expect( + fetchOptionalRollbackEndpoint( + missing, + "product_identity_funnel", + {}, + () => ({ linkedUsers: 1 }), + ), + ).resolves.toEqual({ available: false, rows: [] }); + + const unavailable = async () => { + throw new AdminAnalyticsRequestError("unavailable", 503); + }; + await expect( + fetchOptionalRollbackEndpoint( + unavailable, + "product_identity_funnel", + {}, + () => ({ linkedUsers: 1 }), + ), + ).rejects.toMatchObject({ status: 503 }); + }); + + it("decodes the retained aggregate schema during rollback", () => { + expect( + decodeProductEventsResponse({ + data: [ + { + date: "2026-07-31", + event_name: "purchase_completed", + source: "server", + platform: "web", + app_version: "web", + hostname: "cap.so", + country: "GB", + device: "desktop", + browser: "Chrome", + os: "macOS", + channel: "direct", + plan_id: "price_pro", + payment_status: "paid", + subscription_status: "active", + currency: "GBP", + billing_interval: "month", + events: 1, + actors: 1, + users: 1, + organizations: 1, + revenue_minor: 2_500, + }, + ], + }), + ).toMatchObject([ + { changeKind: "", recordingStatus: "", revenueMinor: 2_500 }, + ]); + expect( + decodeProductEventsResponse({ + data: [ + { + date: "2026-07-31", + event_name: "recording_completed", + source: "client", + platform: "desktop", + app_version: "1.0.0", + hostname: "", + country: "GB", + device: "desktop", + browser: "", + os: "macOS", + channel: "direct", + plan_id: "", + recording_status: "failed", + payment_status: "", + subscription_status: "", + currency: "", + billing_interval: "", + events: 2, + actors: 2, + users: 2, + organizations: 1, + revenue_minor: 0, + }, + ], + }), + ).toMatchObject([ + { eventName: "recording_completed", recordingStatus: "failed" }, + ]); + expect( + decodeAnalyticsFreshnessResponse({ + data: [ + { + latest_received_hour: "2026-07-31 10:00:00", + health_freshness_ms: 1_000, + product_calculated_at: "2026-07-31 10:01:00", + traffic_calculated_at: "2026-07-31 10:01:00", + retention_calculated_at: "2026-07-31 10:01:00", + }, + ], + }), + ).toMatchObject([{ identityCalculatedAt: "" }]); + }); + + it("decodes numeric Tinybird values without accepting malformed rows", () => { + expect( + decodeTrafficOverviewResponse({ + data: [ + { + date: "2026-07-31", + visitors: "12", + visits: 14, + pageviews: "28", + views_per_visit: 2, + bounce_rate: "25.5", + visit_duration_ms: 60_000, + engaged_ms: "120000", + }, + ], + }), + ).toEqual([ + { + date: "2026-07-31", + visitors: 12, + visits: 14, + pageviews: 28, + viewsPerVisit: 2, + bounceRate: 25.5, + visitDurationMs: 60_000, + engagedMs: 120_000, + }, + ]); + + expect(() => + decodeTrafficOverviewResponse({ data: [{ date: 42 }] }), + ).toThrow(AdminAnalyticsRequestError); + expect(() => decodeTrafficOverviewResponse({ rows: [] })).toThrow( + AdminAnalyticsRequestError, + ); + }); + + it("decodes exact traffic totals and explicit attribution models", () => { + expect( + decodeTrafficTotalsResponse({ + data: [ + { + visitors: "9", + visits: 12, + pageviews: "24", + views_per_visit: 2, + bounce_rate: "25", + visit_duration_ms: 30_000, + engaged_ms: "60000", + }, + ], + }), + ).toEqual([ + { + visitors: 9, + visits: 12, + pageviews: 24, + viewsPerVisit: 2, + bounceRate: 25, + visitDurationMs: 30_000, + engagedMs: 60_000, + }, + ]); + expect( + decodeTrafficAttributionResponse({ + data: [ + { + attribution_model: "last", + source: "newsletter", + medium: "email", + campaign: "launch", + visitors: "7", + visits: "8", + pageviews: "11", + }, + ], + }), + ).toEqual([ + { + attributionModel: "last", + source: "newsletter", + medium: "email", + campaign: "launch", + visitors: 7, + visits: 8, + pageviews: 11, + }, + ]); + expect(() => + decodeTrafficAttributionResponse({ + data: [ + { + attribution_model: "inferred", + source: "", + medium: "", + campaign: "", + visitors: 1, + visits: 1, + pageviews: 1, + }, + ], + }), + ).toThrow(AdminAnalyticsRequestError); + }); + + it("decodes aggregate experiment outcomes without identifiers", () => { + expect( + decodeExperimentOutcomesResponse({ + data: [ + { + experiment_id: "pricing-copy", + assignment_version: "v2", + variant: "concise", + platform: "web", + app_version: "web", + outcome_name: "paid_purchase", + exposed_actors: "40", + converted_actors: "6", + conversion_rate: "15", + }, + ], + }), + ).toEqual([ + { + experimentId: "pricing-copy", + assignmentVersion: "v2", + variant: "concise", + platform: "web", + appVersion: "web", + outcomeName: "paid_purchase", + exposedActors: 40, + convertedActors: 6, + conversionRate: 15, + }, + ]); + }); +}); diff --git a/apps/web/__tests__/unit/agent-api-contract.test.ts b/apps/web/__tests__/unit/agent-api-contract.test.ts index 18078ced76b..1ac5ef35eeb 100644 --- a/apps/web/__tests__/unit/agent-api-contract.test.ts +++ b/apps/web/__tests__/unit/agent-api-contract.test.ts @@ -525,7 +525,8 @@ describe("agent API contract", () => { expect(importSource).toContain("agentOperationId: operationId"); expect(importSource).not.toContain("loomDownloadUrl:"); expect(workflow).toContain("claimAgentImport"); - expect(workflow).toContain("completeAgentImport"); + expect(workflow).toContain("saveMetadataAndComplete"); + expect(workflow).toContain("persistProductAnalyticsEvent"); expect(workflow).toContain("failAgentImport"); }); }); diff --git a/apps/web/__tests__/unit/deliver-product-analytics-outbox.test.ts b/apps/web/__tests__/unit/deliver-product-analytics-outbox.test.ts new file mode 100644 index 00000000000..71e7a63a3b2 --- /dev/null +++ b/apps/web/__tests__/unit/deliver-product-analytics-outbox.test.ts @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const outboxSource = readFileSync( + new URL("../../lib/analytics/product-event-outbox.ts", import.meta.url), + "utf8", +); +const stateSource = readFileSync( + new URL("../../lib/analytics/product-event-outbox-state.ts", import.meta.url), + "utf8", +); +const deliverySource = readFileSync( + new URL( + "../../workflows/product-analytics-delivery-workflow.ts", + import.meta.url, + ), + "utf8", +); +const erasureSource = readFileSync( + new URL( + "../../../../packages/web-backend/src/Tinybird/ProductAnalyticsErasureLeaseRepo.ts", + import.meta.url, + ), + "utf8", +); +const reconciliationSource = readFileSync( + new URL("../../workflows/reconcile-product-analytics.ts", import.meta.url), + "utf8", +); + +describe("product analytics outbox lifecycle", () => { + it("keeps the first payload immutable and exposes a conflicting replay", () => { + expect(outboxSource).toContain( + "productAnalyticsOutbox.payloadHash} <> $" + "{row.payload_hash}", + ); + expect(outboxSource).toContain("'payload_conflict'"); + expect(outboxSource).not.toMatch( + /set:\s*\{[^}]*payloadHash:\s*row\.payload_hash/s, + ); + }); + + it("requeues only a same-hash dead letter after repair", () => { + expect(outboxSource).toContain( + "productAnalyticsOutbox.payloadHash} = $" + "{row.payload_hash}", + ); + expect(outboxSource).toContain( + "productAnalyticsOutbox.deadLetteredAt} IS NOT NULL, 'pending'", + ); + }); + + it("cannot overwrite a terminal result when delivery finishes before start returns", () => { + const workflowStart = outboxSource.indexOf( + "await start(deliverProductAnalyticsRowWorkflow", + ); + const postStartStatus = outboxSource.indexOf( + 'eq(productAnalyticsOutbox.status, "pending")', + workflowStart, + ); + const leaseFence = outboxSource.indexOf( + "productAnalyticsOutbox.leaseOwnerId", + postStartStatus, + ); + + expect(workflowStart).toBeGreaterThan(-1); + expect(postStartStatus).toBeGreaterThan(workflowStart); + expect(leaseFence).toBeGreaterThan(postStartStatus); + expect(stateSource).toContain( + 'inArray(productAnalyticsOutbox.status, ["pending", "workflow_started"])', + ); + }); + + it("keeps identity-bearing payloads out of Workflow history", () => { + expect(outboxSource).toContain( + "await start(deliverProductAnalyticsRowWorkflow, [", + ); + expect(outboxSource).toContain("pending.deliveryKey"); + expect(deliverySource).toContain( + "deliverProductAnalyticsRowWorkflow(deliveryKey: string)", + ); + expect(deliverySource).not.toContain( + "deliverProductAnalyticsRowWorkflow(row: ProductEventRow)", + ); + expect(reconciliationSource).toContain("return { failed, reconciled }"); + expect(reconciliationSource).toContain( + "loadProductAnalyticsReconciliationPageStep", + ); + expect(reconciliationSource).toContain( + "loadStripeAnalyticsReconciliationPageStep", + ); + expect(reconciliationSource).not.toContain("return { events: reconciled"); + }); + + it("terminalizes permanent outcomes and recovers unconfirmed workflows", () => { + expect(deliverySource).toContain( + "markProductAnalyticsOutboxDelivered(deliveryKey, row.payload_hash)", + ); + expect(deliverySource).toContain( + "deleteSuppressedProductAnalyticsOutboxRow", + ); + expect(deliverySource).toContain('"provider_rejected"'); + expect(deliverySource).toContain('"staging_timeout_after_accept"'); + expect(outboxSource).toContain( + 'eq(productAnalyticsOutbox.status, "workflow_started")', + ); + expect(outboxSource).toContain('lastErrorCode: "delivery_unconfirmed"'); + }); + + it("bounds recovery capacity and delivered-row retention", () => { + expect(outboxSource).toContain("const MAX_DRAIN_BATCH_SIZE = 500"); + expect(outboxSource).toContain("const DRAIN_CONCURRENCY = 10"); + expect(outboxSource).toContain( + "const DELIVERED_RETENTION_MS = 31 * 24 * 60 * 60 * 1_000", + ); + expect(outboxSource).toContain("capacityPerDay"); + expect(outboxSource).toContain("oldestPendingAgeSeconds"); + expect(outboxSource).toContain("oldestDeadLetterAgeSeconds"); + expect(outboxSource).toContain("payloadConflict"); + expect(outboxSource).toContain("receiptPayloadConflictAttempts"); + expect(outboxSource).toContain("const CLEANUP_BATCH_SIZE = 1_000"); + }); + + it("fences user, organization and anonymous outbox rows during erasure", () => { + expect(erasureSource).toContain( + "Db.productAnalyticsOutbox.userId, scope.userId", + ); + expect(erasureSource).toContain("Db.productAnalyticsOutbox.organizationId"); + expect(erasureSource).toContain("Db.productAnalyticsOutbox.anonymousId"); + expect(erasureSource).toContain("Db.productAnalyticsIdentityState"); + expect(erasureSource).toContain("delete(Db.productAnalyticsOutbox)"); + expect(outboxSource).toContain('.for("update")'); + expect(outboxSource).toContain('status: "suppressed" as const'); + }); +}); diff --git a/apps/web/__tests__/unit/developer-credits-webhook.test.ts b/apps/web/__tests__/unit/developer-credits-webhook.test.ts index 51362a9f728..30297983f97 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", }), @@ -59,10 +55,20 @@ vi.mock("@/lib/developer-credits", () => ({ addCreditsToAccount: (...args: unknown[]) => mockAddCredits(...args), })); -vi.mock("@cap/web-domain", () => ({ - Organisation: { OrganisationId: { make: (v: string) => v } }, - User: { UserId: { make: (v: string) => v } }, -})); +vi.mock("@cap/web-domain", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Organisation: { + ...actual.Organisation, + OrganisationId: { make: (value: string) => value }, + }, + User: { + ...actual.User, + UserId: { make: (value: string) => value }, + }, + }; +}); const mockStripe = { webhooks: { @@ -86,13 +92,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..498fcf9581a --- /dev/null +++ b/apps/web/__tests__/unit/guest-checkout-analytics.test.ts @@ -0,0 +1,71 @@ +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, + queueServerProductEvent: mocks.product, +})); + +describe("guest checkout analytics", () => { + let POST: typeof import("@/app/api/settings/billing/guest-checkout/route").POST; + + beforeEach(async () => { + vi.clearAllMocks(); + mocks.product.mockResolvedValue(undefined); + mocks.createSession.mockResolvedValue({ + id: "cs_guest_1", + created: 1_754_000_000, + 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/loom-product-analytics.test.ts b/apps/web/__tests__/unit/loom-product-analytics.test.ts new file mode 100644 index 00000000000..2ba9f413e43 --- /dev/null +++ b/apps/web/__tests__/unit/loom-product-analytics.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + queueDurableServerProductEvent: vi.fn(), +})); + +vi.mock("@/lib/analytics/product-event-outbox", () => ({ + attemptProductAnalyticsOutboxDelivery: vi.fn(), + persistProductAnalyticsEvent: vi.fn(), + queueDurableServerProductEvent: mocks.queueDurableServerProductEvent, +})); + +import { queueLoomAnalyticsEvent } from "@/workflows/import-loom-video"; + +describe("Loom product analytics delivery", () => { + beforeEach(() => { + mocks.queueDurableServerProductEvent.mockReset(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("surfaces persistence failure so the durable workflow retries", async () => { + mocks.queueDurableServerProductEvent.mockRejectedValue( + new Error("database unavailable"), + ); + + await expect( + queueLoomAnalyticsEvent({ + eventId: "loom_import:video-1:completed", + eventName: "loom_import_completed", + occurredAt: "2026-08-01T12:00:00.000Z", + platform: "server", + userId: "user-1", + organizationId: "org-1", + properties: { import_mode: "direct", duration_ms: 1_000 }, + }), + ).rejects.toThrow("database unavailable"); + }); + + it("commits completion and its outbox fact in one transaction", () => { + const source = readFileSync( + new URL("../../workflows/import-loom-video.ts", import.meta.url), + "utf8", + ); + const completionFunction = source.indexOf( + "async function saveMetadataAndComplete", + ); + const transaction = source.indexOf( + "await db().transaction", + completionFunction, + ); + const eventPersistence = source.indexOf( + "await persistProductAnalyticsEvent(tx, completedEvent)", + transaction, + ); + const completionDelivery = source.indexOf("await startLoomAnalyticsEvent"); + const businessCatch = source.indexOf("} catch (error)"); + + expect(transaction).toBeGreaterThan(completionFunction); + expect(eventPersistence).toBeGreaterThan(transaction); + expect(completionDelivery).toBeGreaterThan(businessCatch); + }); +}); diff --git a/apps/web/__tests__/unit/mobile-checkout.test.ts b/apps/web/__tests__/unit/mobile-checkout.test.ts index dee16ab7e58..768fc77b042 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, + queueServerProductEvent: checkoutMocks.product, })); const makeGuestCheckoutRequest = (body: Record) => @@ -45,8 +39,10 @@ const makeGuestCheckoutRequest = (body: Record) => describe("checkout redirects", () => { beforeEach(() => { vi.clearAllMocks(); + checkoutMocks.product.mockResolvedValue(undefined); checkoutMocks.create.mockResolvedValue({ id: "cs_test", + created: 1_754_000_000, url: "https://pay.cap.so/session", }); }); @@ -72,7 +68,8 @@ describe("checkout redirects", () => { ); expect(response.status).toBe(200); - expect(checkoutMocks.create).toHaveBeenCalledWith({ + const checkout = checkoutMocks.create.mock.calls[0]?.[0]; + expect(checkout).toMatchObject({ line_items: [{ price: "price_pro", quantity: 1 }], mode: "subscription", success_url: @@ -82,8 +79,14 @@ describe("checkout redirects", () => { metadata: { platform: "web", guestCheckout: "true", + analyticsSchemaVersion: "1", + analyticsPriceId: "price_pro", + analyticsQuantity: "1", + analyticsIsFirstPurchase: "true", + analyticsAnonymousId: expect.stringMatching(/^guest:/), }, }); + expect(checkout.subscription_data.metadata).toEqual(checkout.metadata); }); it("sends mobile checkout results through the HTTPS completion route", () => { @@ -111,6 +114,11 @@ describe("checkout redirects", () => { metadata: { platform: "mobile", guestCheckout: "true", + analyticsSchemaVersion: "1", + analyticsPriceId: "price_pro", + analyticsQuantity: "1", + analyticsIsFirstPurchase: "true", + analyticsAnonymousId: expect.stringMatching(/^guest:/), }, }), ); diff --git a/apps/web/__tests__/unit/organization-invite-delivery.test.ts b/apps/web/__tests__/unit/organization-invite-delivery.test.ts new file mode 100644 index 00000000000..e6e23bb3f22 --- /dev/null +++ b/apps/web/__tests__/unit/organization-invite-delivery.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync( + new URL("../../lib/organization-invite-delivery.ts", import.meta.url), + "utf8", +); + +describe("organization invite delivery recovery", () => { + it("repeats provider delivery with a stable idempotency key", () => { + expect(source).toContain( + "idempotencyKey: `organization-invite:$" + "{claimed.id}`", + ); + expect(source).toContain('emailDeliveryState, "pending"'); + expect(source).toContain("emailDeliveryNextAttemptAt"); + expect(source).toContain('deadLettered ? "dead_letter" : "pending"'); + expect(source).toContain("requeueOrganizationInviteDelivery"); + }); + + it("sends outside the claim transaction and fences durable completion", () => { + const claimTransaction = source.indexOf("await db().transaction"); + const providerSend = source.indexOf("await sendEmail", claimTransaction); + const completionTransaction = source.indexOf( + "const eventId = await db().transaction", + providerSend, + ); + const sentState = source.indexOf( + 'emailDeliveryState: "sent"', + completionTransaction, + ); + const outboxFact = source.indexOf( + "await persistProductAnalyticsEvent(tx", + completionTransaction, + ); + + expect(claimTransaction).toBeGreaterThan(-1); + expect(providerSend).toBeGreaterThan(claimTransaction); + expect(completionTransaction).toBeGreaterThan(providerSend); + expect(sentState).toBeGreaterThan(completionTransaction); + expect(outboxFact).toBeGreaterThan(sentState); + expect(source).toContain( + "await attemptProductAnalyticsOutboxDelivery(eventId)", + ); + expect(source).toContain("isNull(organizations.tombstoneAt)"); + expect(source).toContain("emailDeliveryLeaseOwnerId"); + expect(source).toContain("if (!serverEnv().RESEND_API_KEY)"); + expect(source).toContain('.for("update")'); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-authentication.test.ts b/apps/web/__tests__/unit/product-analytics-authentication.test.ts new file mode 100644 index 00000000000..8251c5c1d35 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-authentication.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + row: { organizationId: "org-1" } as { organizationId: string } | undefined, + cookieValue: "anonymous-1" as string | undefined, + persist: vi.fn(), + queue: vi.fn(), + transaction: vi.fn(), +})); + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => (mocks.row ? [mocks.row] : []), + }), + }), + }), + transaction: mocks.transaction, + }), +})); + +vi.mock("@cap/database/schema", () => ({ + users: { activeOrganizationId: "activeOrganizationId", id: "id" }, +})); + +vi.mock("drizzle-orm", () => ({ eq: vi.fn(() => "condition") })); + +vi.mock("next/headers", () => ({ + cookies: async () => ({ + get: () => + mocks.cookieValue === undefined + ? undefined + : { value: mocks.cookieValue }, + }), +})); + +vi.mock("@/lib/analytics/product-event-outbox", () => ({ + persistProductAnalyticsEvent: mocks.persist, + queueDurableServerProductEvent: mocks.queue, +})); + +describe("recordWebAuthenticationSuccess", () => { + beforeEach(() => { + mocks.row = { organizationId: "org-1" }; + mocks.cookieValue = "anonymous-1"; + mocks.persist.mockReset().mockResolvedValue({ status: "pending" }); + mocks.queue.mockReset().mockResolvedValue({ status: "delivered" }); + mocks.transaction + .mockReset() + .mockImplementation(async (callback) => callback({ transaction: true })); + }); + + it("durably records sign-in and anonymous identity stitching", async () => { + const { recordWebAuthenticationSuccess } = await import( + "@/lib/analytics/authentication-events" + ); + + await recordWebAuthenticationSuccess("user-1"); + + expect(mocks.persist).toHaveBeenCalledTimes(2); + expect(mocks.queue).toHaveBeenCalledTimes(2); + expect(mocks.persist.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ + eventName: "user_signed_in", + platform: "web", + userId: "user-1", + organizationId: "org-1", + anonymousId: "anonymous-1", + }), + expect.objectContaining({ + eventName: "identity_linked", + anonymousId: "anonymous-1", + }), + ]); + }); + + it("records authenticated sign-in without inventing an anonymous link", async () => { + mocks.cookieValue = undefined; + const { recordWebAuthenticationSuccess } = await import( + "@/lib/analytics/authentication-events" + ); + + await recordWebAuthenticationSuccess("user-1"); + + expect(mocks.persist).toHaveBeenCalledOnce(); + expect(mocks.queue).toHaveBeenCalledOnce(); + expect(mocks.persist.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ eventName: "user_signed_in" }), + ); + }); + + it("fails closed before delivery when the durable write fails", async () => { + mocks.persist.mockRejectedValueOnce(new Error("database unavailable")); + const { recordWebAuthenticationSuccess } = await import( + "@/lib/analytics/authentication-events" + ); + + await expect(recordWebAuthenticationSuccess("user-1")).rejects.toThrow( + "database unavailable", + ); + expect(mocks.queue).not.toHaveBeenCalled(); + }); +}); 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..01757e77f4f --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-browser-token.test.ts @@ -0,0 +1,94 @@ +import { createHmac } from "node:crypto"; +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("refuses to create or accept a token containing a personal identifier", () => { + expect(() => + createProductAnalyticsBrowserToken(secret, "alice@example.com", now), + ).toThrow("Analytics anonymous ID is invalid"); + const issuedAt = Math.floor(now / 1_000); + const payload = `v1.${issuedAt}.alice@example.com`; + const signature = createHmac("sha256", secret) + .update(payload) + .digest("base64url"); + expect( + readProductAnalyticsBrowserTokenClaims( + `${payload}.${signature}`, + secret, + now, + ), + ).toBeUndefined(); + }); + + 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-delivery.test.ts b/apps/web/__tests__/unit/product-analytics-delivery.test.ts new file mode 100644 index 00000000000..b2cd4060958 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-delivery.test.ts @@ -0,0 +1,293 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + identityRows: [[], [{ id: "user-1" }], [{ id: "org-1" }]] as unknown[][], + identitySelectIndex: 0, + vercelEnv: "production", + storedRow: { + eventId: "signup:user-1", + lastErrorCode: null as string | null, + payloadHash: "a".repeat(32), + payloadKind: "product_event_row_v1", + payload: { + event_id: "signup:user-1", + event_name: "user_signed_up", + payload_hash: "a".repeat(32), + received_at: "2026-07-12T12:00:00.000Z", + properties: "{}", + user_id: "user-1", + organization_id: "org-1", + synthetic_run_id: "", + }, + }, + deleteSuppressed: vi.fn(), + sendProductAnalyticsRows: vi.fn(), + markDeadLetter: vi.fn(), + markDelivered: vi.fn(), + markRetrying: vi.fn(), + acquireIngestionLease: vi.fn(), + releaseIngestionLease: vi.fn(), +})); + +vi.mock("@/lib/analytics/product-event-outbox-state", () => ({ + acquireProductAnalyticsIngestionLease: mocks.acquireIngestionLease, + deleteSuppressedProductAnalyticsOutboxRow: mocks.deleteSuppressed, + markProductAnalyticsOutboxDeadLetter: mocks.markDeadLetter, + markProductAnalyticsOutboxDelivered: mocks.markDelivered, + markProductAnalyticsOutboxRetrying: mocks.markRetrying, + releaseProductAnalyticsIngestionLease: mocks.releaseIngestionLease, +})); + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: (selection: Record) => { + if ("eventId" in selection) { + return { + from: () => ({ + where: () => ({ limit: async () => [mocks.storedRow] }), + }), + }; + } + if ("userId" in selection) { + return { from: () => ({ where: () => ({}) }) }; + } + return { + from: () => ({ + where: () => ({ + limit: async () => + mocks.identityRows[mocks.identitySelectIndex++] ?? [], + }), + }), + }; + }, + }), +})); + +vi.mock("drizzle-orm", async (importOriginal) => ({ + ...(await importOriginal()), + and: (...conditions: unknown[]) => conditions, + eq: () => true, + isNull: () => true, + notInArray: () => true, +})); + +vi.mock("@cap/analytics", async (importOriginal) => ({ + ...(await importOriginal()), + sendProductAnalyticsRows: mocks.sendProductAnalyticsRows, +})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ + PRODUCT_ANALYTICS_TINYBIRD_HOST: "https://staging.tinybird.test", + PRODUCT_ANALYTICS_TINYBIRD_TOKEN: "ingest-token", + VERCEL_ENV: mocks.vercelEnv, + }), +})); + +vi.mock("workflow", () => ({ + FatalError: class FatalError extends Error {}, +})); + +vi.mock("workflow/api", () => ({ + start: vi.fn(), +})); + +import { deliverProductAnalyticsRowStep } from "@/workflows/product-analytics-delivery-workflow"; + +const deliveryKey = "00000000-0000-4000-8000-000000000001"; + +describe("durable product analytics delivery", () => { + beforeEach(() => { + mocks.identityRows = [[], [{ id: "user-1" }], [{ id: "org-1" }]]; + mocks.identitySelectIndex = 0; + mocks.vercelEnv = "production"; + mocks.storedRow = { + eventId: "signup:user-1", + lastErrorCode: null, + payloadHash: "a".repeat(32), + payloadKind: "product_event_row_v1", + payload: { + event_id: "signup:user-1", + event_name: "user_signed_up", + payload_hash: "a".repeat(32), + received_at: "2026-07-12T12:00:00.000Z", + properties: "{}", + user_id: "user-1", + organization_id: "org-1", + synthetic_run_id: "", + }, + }; + mocks.deleteSuppressed.mockReset().mockResolvedValue(undefined); + mocks.sendProductAnalyticsRows.mockReset().mockResolvedValue(undefined); + mocks.markDeadLetter.mockReset().mockResolvedValue(undefined); + mocks.markDelivered.mockReset().mockResolvedValue(undefined); + mocks.markRetrying.mockReset().mockResolvedValue(undefined); + mocks.acquireIngestionLease + .mockReset() + .mockResolvedValue("ingestion-lease-1"); + mocks.releaseIngestionLease.mockReset().mockResolvedValue(undefined); + }); + + it("suppresses a user that is missing or pending account deletion", async () => { + mocks.identityRows = [[], [], [{ id: "org-1" }]]; + + await expect(deliverProductAnalyticsRowStep(deliveryKey)).resolves.toEqual({ + status: "suppressed", + }); + expect(mocks.sendProductAnalyticsRows).not.toHaveBeenCalled(); + expect(mocks.deleteSuppressed).toHaveBeenCalledWith( + deliveryKey, + mocks.storedRow.payloadHash, + ); + }); + + it("suppresses an organization after its tombstone is written", async () => { + mocks.identityRows = [[], [{ id: "user-1" }], []]; + + await expect(deliverProductAnalyticsRowStep(deliveryKey)).resolves.toEqual({ + status: "suppressed", + }); + expect(mocks.sendProductAnalyticsRows).not.toHaveBeenCalled(); + }); + + it("delivers once when both durable identities remain active", async () => { + await expect(deliverProductAnalyticsRowStep(deliveryKey)).resolves.toEqual({ + status: "delivered", + }); + expect(mocks.sendProductAnalyticsRows).toHaveBeenCalledTimes(1); + expect(mocks.sendProductAnalyticsRows).toHaveBeenCalledWith( + expect.objectContaining({ maxAttempts: 1, wait: true }), + ); + expect(mocks.markDelivered).toHaveBeenCalledWith( + deliveryKey, + mocks.storedRow.payloadHash, + ); + }); + + it("dead-letters a permanent provider rejection", async () => { + const { ProductAnalyticsError } = await import("@cap/analytics"); + mocks.sendProductAnalyticsRows.mockRejectedValueOnce( + new ProductAnalyticsError({ + cause: new Error("rejected"), + retryable: false, + status: 400, + }), + ); + + await expect(deliverProductAnalyticsRowStep(deliveryKey)).rejects.toThrow( + "permanently rejected", + ); + expect(mocks.markDeadLetter).toHaveBeenCalledWith( + deliveryKey, + mocks.storedRow.payloadHash, + "provider_rejected", + ); + }); + + it("keeps a retryable provider failure observable", async () => { + mocks.sendProductAnalyticsRows.mockRejectedValueOnce( + new Error("temporary"), + ); + + await expect(deliverProductAnalyticsRowStep(deliveryKey)).rejects.toThrow( + "temporarily failed", + ); + expect(mocks.markRetrying).toHaveBeenCalledWith( + deliveryKey, + mocks.storedRow.payloadHash, + ); + }); + + it("retries a preview purchase after a simulated lost acknowledgement", async () => { + mocks.vercelEnv = "preview"; + mocks.identityRows = [[], []]; + mocks.storedRow = { + ...mocks.storedRow, + eventId: "staging_ambiguous_purchase_1", + payload: { + ...mocks.storedRow.payload, + event_id: "staging_ambiguous_purchase_1", + event_name: "purchase_completed", + synthetic_run_id: "run_staging_server", + }, + }; + + await expect(deliverProductAnalyticsRowStep(deliveryKey)).rejects.toThrow( + "acknowledgement lost", + ); + expect(mocks.markRetrying).toHaveBeenCalledWith( + deliveryKey, + mocks.storedRow.payloadHash, + "staging_timeout_after_accept", + ); + + mocks.storedRow.lastErrorCode = "staging_timeout_after_accept"; + await expect(deliverProductAnalyticsRowStep(deliveryKey)).resolves.toEqual({ + status: "delivered", + }); + expect(mocks.sendProductAnalyticsRows).toHaveBeenCalledTimes(2); + }); + + it.each([ + ["429", "staging_provider_429"], + ["503", "staging_provider_503"], + ] as const)( + "retries a preview provider %s response before delivery", + async (status, errorCode) => { + mocks.vercelEnv = "preview"; + mocks.identityRows = [[], []]; + mocks.storedRow = { + ...mocks.storedRow, + eventId: `staging_retry_${status}_event_1`, + payload: { + ...mocks.storedRow.payload, + event_id: `staging_retry_${status}_event_1`, + synthetic_run_id: "run_staging_server", + }, + }; + + await expect(deliverProductAnalyticsRowStep(deliveryKey)).rejects.toThrow( + `returned ${status}`, + ); + expect(mocks.sendProductAnalyticsRows).not.toHaveBeenCalled(); + expect(mocks.markRetrying).toHaveBeenCalledWith( + deliveryKey, + mocks.storedRow.payloadHash, + errorCode, + ); + + mocks.storedRow.lastErrorCode = errorCode; + await expect( + deliverProductAnalyticsRowStep(deliveryKey), + ).resolves.toEqual({ + status: "delivered", + }); + expect(mocks.sendProductAnalyticsRows).toHaveBeenCalledTimes(1); + }, + ); + + it("dead-letters a preview contract rejection without provider delivery", async () => { + mocks.vercelEnv = "preview"; + mocks.identityRows = [[], []]; + mocks.storedRow = { + ...mocks.storedRow, + eventId: "staging_reject_400_event_1", + payload: { + ...mocks.storedRow.payload, + event_id: "staging_reject_400_event_1", + synthetic_run_id: "run_staging_server", + }, + }; + + await expect(deliverProductAnalyticsRowStep(deliveryKey)).rejects.toThrow( + "returned 400", + ); + expect(mocks.markDeadLetter).toHaveBeenCalledWith( + deliveryKey, + mocks.storedRow.payloadHash, + "provider_rejected", + ); + expect(mocks.sendProductAnalyticsRows).not.toHaveBeenCalled(); + expect(mocks.acquireIngestionLease).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-erasure.test.ts b/apps/web/__tests__/unit/product-analytics-erasure.test.ts new file mode 100644 index 00000000000..7f2f1703ae7 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-erasure.test.ts @@ -0,0 +1,621 @@ +import { + ProductAnalyticsErasureLeaseRepo, + type ProductAnalyticsErasureLeaseStore, + Tinybird, +} from "@cap/web-backend"; +import { Effect, Layer } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const serviceEnvironment = vi.hoisted( + (): { + PRODUCT_ANALYTICS_TINYBIRD_COPY_TOKEN: string | undefined; + PRODUCT_ANALYTICS_TINYBIRD_ERASURE_LOOKUP_TOKEN: string | undefined; + PRODUCT_ANALYTICS_TINYBIRD_ERASURE_TOKEN: string | undefined; + PRODUCT_ANALYTICS_TINYBIRD_HOST: string | undefined; + PRODUCT_ANALYTICS_TINYBIRD_READ_TOKEN: string | undefined; + PRODUCT_ANALYTICS_TINYBIRD_SCHEDULER_TOKEN: string | undefined; + TINYBIRD_HOST: undefined; + TINYBIRD_TOKEN: undefined; + } => ({ + PRODUCT_ANALYTICS_TINYBIRD_COPY_TOKEN: "copy-token", + PRODUCT_ANALYTICS_TINYBIRD_ERASURE_LOOKUP_TOKEN: "lookup-token", + PRODUCT_ANALYTICS_TINYBIRD_ERASURE_TOKEN: "erasure-token", + PRODUCT_ANALYTICS_TINYBIRD_HOST: "https://staging.tinybird.test", + PRODUCT_ANALYTICS_TINYBIRD_READ_TOKEN: "read-token", + PRODUCT_ANALYTICS_TINYBIRD_SCHEDULER_TOKEN: "scheduler-token", + TINYBIRD_HOST: undefined, + TINYBIRD_TOKEN: undefined, + }), +); + +vi.mock("@cap/env", () => ({ serverEnv: () => serviceEnvironment })); + +const tinybirdTestLayer = ( + overrides: Partial = {}, +) => { + const store = { + enqueueErasureRequest: () => Effect.succeed("request-1"), + claimErasureRequest: () => + Effect.succeed({ + id: "request-1", + ownerId: "request-owner-1", + scope: { organizationId: "organization-1", userId: "user-1" }, + }), + completeErasureRequest: () => Effect.void, + deferErasureRequest: () => Effect.void, + waitForIngestionQuiescence: () => Effect.succeed(undefined), + discardPendingEvents: (_scope, anonymousIds = []) => + Effect.succeed([...anonymousIds]), + claimNew: (scope) => + Effect.succeed({ + ownerId: "owner-1", + requestId: "request-1", + fencingToken: 1, + phase: "claimed" as const, + pausedPipes: [], + scope, + }), + claimRecovery: () => Effect.succeed(null), + heartbeat: () => Effect.succeed(true), + advance: () => Effect.succeed(true), + complete: () => Effect.succeed(true), + fail: () => Effect.succeed(true), + ...overrides, + } satisfies ProductAnalyticsErasureLeaseStore; + return Tinybird.DefaultWithoutDependencies.pipe( + Layer.provide( + Layer.succeed(ProductAnalyticsErasureLeaseRepo, { + _tag: "ProductAnalyticsErasureLeaseRepo", + ...store, + }), + ), + ); +}; + +const copyScheduleResponse = (url: URL, pausedPipes: Set) => { + const match = url.pathname.match( + /^\/v0\/pipes\/([A-Za-z0-9_]+)(?:\/copy\/(cancel|resume))?$/, + ); + if (!match?.[1]) return undefined; + const pipe = match[1]; + if (match[2] === "cancel") pausedPipes.add(pipe); + if (match[2] === "resume") pausedPipes.delete(pipe); + return Response.json({ + schedule: { status: pausedPipes.has(pipe) ? "paused" : "scheduled" }, + }); +}; + +afterEach(() => { + vi.unstubAllGlobals(); + serviceEnvironment.PRODUCT_ANALYTICS_TINYBIRD_COPY_TOKEN = "copy-token"; + serviceEnvironment.PRODUCT_ANALYTICS_TINYBIRD_ERASURE_LOOKUP_TOKEN = + "lookup-token"; + serviceEnvironment.PRODUCT_ANALYTICS_TINYBIRD_ERASURE_TOKEN = "erasure-token"; + serviceEnvironment.PRODUCT_ANALYTICS_TINYBIRD_HOST = + "https://staging.tinybird.test"; + serviceEnvironment.PRODUCT_ANALYTICS_TINYBIRD_READ_TOKEN = "read-token"; + serviceEnvironment.PRODUCT_ANALYTICS_TINYBIRD_SCHEDULER_TOKEN = + "scheduler-token"; +}); + +describe.sequential("product analytics erasure", () => { + it("deletes linked identities and rebuilds every derived snapshot", async () => { + const requests: Array<{ url: URL; init: RequestInit }> = []; + const pausedPipes = new Set(); + let deleted = false; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init: RequestInit = {}) => { + const url = new URL(String(input)); + requests.push({ url, init }); + if (/\/copy\/(cancel|resume)$/.test(url.pathname)) { + return Response.json( + { error: "The copy Pipe is not scheduled" }, + { status: 422 }, + ); + } + const scheduleResponse = copyScheduleResponse(url, pausedPipes); + if (scheduleResponse) return scheduleResponse; + if (url.pathname === "/v0/sql") { + const query = url.searchParams.get("q") ?? ""; + if (query.startsWith("SELECT anonymous_id")) { + return Response.json({ data: [{ anonymous_id: "anonymous-1" }] }); + } + return Response.json({ + data: [{ matching_rows: deleted ? 0 : 1 }], + }); + } + if (url.pathname.includes("/delete")) { + deleted = true; + return Response.json({ mutation: { is_done: true } }); + } + if (url.pathname.endsWith("product_analytics_copy_assertions.json")) { + return Response.json({ + data: [ + { + activation_markers: 1, + attribution_markers: 1, + decision_markers: 1, + experiment_markers: 1, + health_markers: 1, + identity_markers: 1, + retention_markers: 1, + traffic_markers: 1, + traffic_page_markers: 1, + }, + ], + }); + } + if (url.pathname === "/v0/jobs") { + return Response.json({ jobs: [] }); + } + if (url.pathname.startsWith("/v0/jobs/")) { + return Response.json({ status: "done" }); + } + if (url.pathname.includes("/copy")) { + const pipe = url.pathname.split("/").at(-2); + return Response.json({ job_id: `${pipe}-job` }); + } + return Response.json({}); + }), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + yield* tinybird.eraseProductAnalytics({ + userId: "user-1", + organizationId: "organization-1", + }); + }).pipe(Effect.provide(tinybirdTestLayer())), + ); + + const identityLookup = requests.find(({ url }) => + (url.searchParams.get("q") ?? "").startsWith("SELECT anonymous_id"), + ); + expect(identityLookup?.url.pathname).toBe("/v0/sql"); + expect(identityLookup?.url.searchParams.get("q")).toContain( + "countIf(user_id != '' AND user_id != 'user-1') = 0", + ); + const deletion = requests.find(({ url }) => + url.pathname.includes("/delete"), + ); + expect(deletion?.url.pathname).toBe( + "/v1/datasources/product_events_v1/delete", + ); + const deleteBody = new URLSearchParams(String(deletion?.init.body)).get( + "delete_condition", + ); + expect(deleteBody).toContain("organization_id"); + expect(deleteBody).toContain("user_id"); + expect(deleteBody).toContain("anonymous_id"); + expect(deleteBody).toContain("AND (user_id = '' OR user_id = 'user-1')"); + expect( + requests + .filter(({ url }) => url.pathname.endsWith("/copy")) + .map(({ url }) => url.pathname), + ).toEqual( + [ + "snapshot_product_event_id_states_v2", + "snapshot_product_event_day_states_v2", + "snapshot_product_events_canonical_v1", + "snapshot_product_events_daily_exact", + "snapshot_product_traffic_daily_exact", + "snapshot_product_traffic_pages_daily_exact", + "snapshot_product_activation_daily_exact", + "snapshot_product_creator_retention_exact", + "snapshot_product_identity_funnel_exact", + "snapshot_product_attribution_daily_exact", + "snapshot_product_experiment_outcomes_exact", + "snapshot_product_events_health_hourly", + ].map((pipe) => `/v0/pipes/${pipe}/copy`), + ); + for (const request of requests.filter(({ url }) => + url.pathname.endsWith("/copy"), + )) { + expect(request.url.searchParams.get("_mode")).toBe("replace"); + } + const jobListRequests = requests.filter( + ({ url }) => url.pathname === "/v0/jobs", + ); + expect(jobListRequests).toHaveLength(12); + expect( + new Set( + jobListRequests.map(({ url }) => url.searchParams.get("pipe_name")), + ).size, + ).toBe(12); + expect( + jobListRequests.every( + ({ init }) => + new Headers(init.headers).get("Authorization") === + "Bearer scheduler-token", + ), + ).toBe(true); + expect( + requests.filter(({ url }) => + /^\/v0\/pipes\/[A-Za-z0-9_]+$/.test(url.pathname), + ), + ).toHaveLength(0); + expect( + requests.filter(({ url }) => url.pathname.startsWith("/v0/jobs/")), + ).toHaveLength(12); + expect( + requests + .filter(({ url }) => url.pathname.startsWith("/v0/jobs/")) + .every( + ({ init }) => + new Headers(init.headers).get("Authorization") === + "Bearer scheduler-token", + ), + ).toBe(true); + expect( + requests + .filter(({ url }) => url.pathname === "/v0/sql") + .every( + ({ init }) => + new Headers(init.headers).get("Authorization") === + "Bearer lookup-token", + ), + ).toBe(true); + expect(new Headers(deletion?.init.headers).get("Authorization")).toBe( + "Bearer erasure-token", + ); + }); + + it("fails closed when the erasure credential is missing", async () => { + serviceEnvironment.PRODUCT_ANALYTICS_TINYBIRD_SCHEDULER_TOKEN = undefined; + + const error = await Effect.runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + yield* tinybird.eraseProductAnalytics({ organizationId: "org-1" }); + }).pipe(Effect.provide(tinybirdTestLayer()), Effect.flip), + ); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe( + "Product analytics Copy schedule control is not configured", + ); + }); + + it("fails closed when the erasure host is missing", async () => { + serviceEnvironment.PRODUCT_ANALYTICS_TINYBIRD_HOST = undefined; + + const error = await Effect.runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + yield* tinybird.eraseProductAnalytics({ organizationId: "org-1" }); + }).pipe(Effect.provide(tinybirdTestLayer()), Effect.flip), + ); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe( + "Product analytics erasure host is not configured", + ); + }); + + it("fails closed before deletion when Copy job state is malformed", async () => { + const requests: URL[] = []; + const pausedPipes = new Set(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + requests.push(url); + const scheduleResponse = copyScheduleResponse(url, pausedPipes); + if (scheduleResponse) return scheduleResponse; + if (url.pathname === "/v0/sql") { + return Response.json({ data: [{ matching_rows: 1 }] }); + } + if (url.pathname === "/v0/jobs") return Response.json({}); + return Response.json({}); + }), + ); + + const error = await Effect.runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + yield* tinybird.eraseProductAnalytics({ organizationId: "org-1" }); + }).pipe(Effect.provide(tinybirdTestLayer()), Effect.flip), + ); + + expect(error.message).toBe( + "Product analytics Jobs API response was invalid", + ); + expect(requests.some((url) => url.pathname.includes("/delete"))).toBe( + false, + ); + }); + + it("fails closed when Tinybird does not confirm deletion", async () => { + const pausedPipes = new Set(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + const scheduleResponse = copyScheduleResponse(url, pausedPipes); + if (scheduleResponse) return scheduleResponse; + if (url.pathname === "/v0/sql") { + return Response.json({ data: [{ matching_rows: 1 }] }); + } + if (url.pathname === "/v0/jobs") { + return Response.json({ jobs: [] }); + } + return Response.json({}); + }), + ); + + const error = await Effect.runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + yield* tinybird.eraseProductAnalytics({ organizationId: "org-1" }); + }).pipe(Effect.provide(tinybirdTestLayer()), Effect.flip), + ); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("Product analytics deletion did not finish"); + }); + + it("rebuilds every snapshot when a retry finds no remaining identity rows", async () => { + const requests: URL[] = []; + const pausedPipes = new Set(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + requests.push(url); + const scheduleResponse = copyScheduleResponse(url, pausedPipes); + if (scheduleResponse) return scheduleResponse; + if (url.pathname === "/v0/sql") { + return Response.json({ data: [{ matching_rows: 0 }] }); + } + if (url.pathname.endsWith("product_analytics_copy_assertions.json")) { + return Response.json({ + data: [ + { + activation_markers: 1, + attribution_markers: 1, + decision_markers: 1, + experiment_markers: 1, + health_markers: 1, + identity_markers: 1, + retention_markers: 1, + traffic_markers: 1, + traffic_page_markers: 1, + }, + ], + }); + } + if (url.pathname === "/v0/jobs") { + return Response.json({ jobs: [] }); + } + if (url.pathname.startsWith("/v0/jobs/")) { + return Response.json({ status: "done" }); + } + if (url.pathname.endsWith("/copy")) { + return Response.json({ job_id: "copy-job" }); + } + return Response.json({}); + }), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + yield* tinybird.eraseProductAnalytics({ organizationId: "org-1" }); + }).pipe(Effect.provide(tinybirdTestLayer())), + ); + + expect(requests.some((url) => url.pathname.includes("/delete"))).toBe( + false, + ); + expect( + requests + .filter((url) => url.pathname.endsWith("/copy")) + .map((url) => url.pathname), + ).toHaveLength(12); + }); + + it("does not start canonical rebuilding until the event-state copy completes", async () => { + const requests: URL[] = []; + const pausedPipes = new Set(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + requests.push(url); + const scheduleResponse = copyScheduleResponse(url, pausedPipes); + if (scheduleResponse) return scheduleResponse; + if (url.pathname === "/v0/sql") { + return Response.json({ data: [{ matching_rows: 0 }] }); + } + if (url.pathname === "/v0/jobs") { + return Response.json({ jobs: [] }); + } + if ( + url.pathname === "/v0/pipes/snapshot_product_event_id_states_v2/copy" + ) { + return Response.json({ job_id: "state-copy-job" }); + } + if (url.pathname === "/v0/jobs/state-copy-job") { + return Response.json({ status: "failed" }); + } + return Response.json({}); + }), + ); + + const error = await Effect.runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + yield* tinybird.eraseProductAnalytics({ organizationId: "org-1" }); + }).pipe(Effect.provide(tinybirdTestLayer()), Effect.flip), + ); + + expect(error.message).toContain("ended in failed"); + expect( + requests.some( + (url) => + url.pathname === + "/v0/pipes/snapshot_product_events_canonical_v1/copy", + ), + ).toBe(false); + }); + + it("resumes schedules already paused when a later pause fails", async () => { + const requests: URL[] = []; + const pausedPipes = new Set(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + requests.push(url); + if ( + url.pathname === + "/v0/pipes/snapshot_product_traffic_daily_exact/copy/cancel" + ) { + return new Response("pause failed", { status: 500 }); + } + const scheduleResponse = copyScheduleResponse(url, pausedPipes); + if (scheduleResponse) return scheduleResponse; + return Response.json({}); + }), + ); + + const error = await Effect.runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + yield* tinybird.eraseProductAnalytics({ organizationId: "org-1" }); + }).pipe(Effect.provide(tinybirdTestLayer()), Effect.flip), + ); + + expect(error).toBeInstanceOf(Error); + expect( + requests + .map((url) => url.pathname) + .filter((pathname) => /\/copy\/(cancel|resume)$/.test(pathname)), + ).toEqual([ + "/v0/pipes/snapshot_product_event_id_states_v2/copy/cancel", + "/v0/pipes/snapshot_product_event_day_states_v2/copy/cancel", + "/v0/pipes/snapshot_product_events_canonical_v1/copy/cancel", + "/v0/pipes/snapshot_product_events_daily_exact/copy/cancel", + "/v0/pipes/snapshot_product_traffic_daily_exact/copy/cancel", + "/v0/pipes/snapshot_product_traffic_daily_exact/copy/cancel", + "/v0/pipes/snapshot_product_traffic_daily_exact/copy/cancel", + "/v0/pipes/snapshot_product_traffic_daily_exact/copy/cancel", + "/v0/pipes/snapshot_product_event_id_states_v2/copy/resume", + "/v0/pipes/snapshot_product_event_day_states_v2/copy/resume", + "/v0/pipes/snapshot_product_events_canonical_v1/copy/resume", + "/v0/pipes/snapshot_product_events_daily_exact/copy/resume", + ]); + }); + + it("durably queues without touching Tinybird when another owner holds the lease", async () => { + const fetch = vi.fn(); + const deferErasureRequest = vi.fn(() => Effect.void); + vi.stubGlobal("fetch", fetch); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + return yield* tinybird.eraseProductAnalytics({ + organizationId: "org-1", + }); + }).pipe( + Effect.provide( + tinybirdTestLayer({ + claimNew: () => Effect.succeed(null), + deferErasureRequest, + }), + ), + ), + ); + + expect(result).toEqual({ queued: true, requestId: "request-1" }); + expect(deferErasureRequest).toHaveBeenCalledOnce(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("recovers a fenced scope and resumes persisted schedules before rebuilding", async () => { + const requests: URL[] = []; + const pausedPipes = new Set(["snapshot_product_events_canonical_v1"]); + const complete = vi.fn(() => Effect.succeed(true)); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + requests.push(url); + const scheduleResponse = copyScheduleResponse(url, pausedPipes); + if (scheduleResponse) return scheduleResponse; + if (url.pathname === "/v0/sql") { + return Response.json({ data: [{ matching_rows: 0 }] }); + } + if (url.pathname.endsWith("product_analytics_copy_assertions.json")) { + return Response.json({ + data: [ + { + activation_markers: 1, + attribution_markers: 1, + decision_markers: 1, + experiment_markers: 1, + health_markers: 1, + identity_markers: 1, + retention_markers: 1, + traffic_markers: 1, + traffic_page_markers: 1, + }, + ], + }); + } + if (url.pathname === "/v0/jobs") { + return Response.json({ jobs: [] }); + } + if (url.pathname.startsWith("/v0/jobs/")) { + return Response.json({ status: "done" }); + } + if (url.pathname.endsWith("/copy")) { + return Response.json({ job_id: "copy-job" }); + } + return Response.json({}); + }), + ); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + return yield* tinybird.recoverProductAnalyticsErasure; + }).pipe( + Effect.provide( + tinybirdTestLayer({ + claimRecovery: () => + Effect.succeed({ + ownerId: "recovery-owner", + requestId: "persisted-request", + fencingToken: 2, + phase: "failed", + pausedPipes: ["snapshot_product_events_canonical_v1"], + scope: { organizationId: "persisted-org" }, + }), + complete, + }), + ), + ), + ); + + expect(result).toEqual({ + recovered: true, + requestId: "persisted-request", + }); + expect(requests[0]?.pathname).toBe( + "/v0/pipes/snapshot_product_events_canonical_v1/copy/resume", + ); + expect( + requests.some((url) => + (url.searchParams.get("q") ?? "").includes( + "organization_id = 'persisted-org'", + ), + ), + ).toBe(true); + expect(complete).toHaveBeenCalledOnce(); + expect(pausedPipes.size).toBe(0); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-event-coverage.test.ts b/apps/web/__tests__/unit/product-analytics-event-coverage.test.ts new file mode 100644 index 00000000000..210fc241ff0 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-event-coverage.test.ts @@ -0,0 +1,79 @@ +import { + EVENT_REGISTRY, + normalizeProductEventInput, + type ProductEventPlatform, +} from "@cap/analytics"; +import { describe, expect, it } from "vitest"; +import { + createServerProductEventRows, + type ServerProductEvent, +} from "@/lib/analytics/server-event"; + +const occurredAt = "2026-07-31T12:00:00.000Z"; + +function runtimeProperties( + properties: Record< + string, + { + type: "boolean" | "number" | "string"; + values?: readonly string[]; + format?: string; + } + >, +) { + return Object.fromEntries( + Object.entries(properties).map(([key, rule]) => { + if (rule.type === "boolean") return [key, true]; + if (rule.type === "number") return [key, 1]; + if (rule.values) return [key, rule.values[0]]; + if (rule.format === "hostname") return [key, "cap.so"]; + if (rule.format === "timestamp") return [key, occurredAt]; + return [key, "bounded_value"]; + }), + ); +} + +describe("product analytics runtime emitter coverage", () => { + it("executes every declared event and platform through an authorized emitter", () => { + const covered = new Set(); + for (const [eventName, definition] of Object.entries(EVENT_REGISTRY)) { + const properties = runtimeProperties(definition.properties); + for (const platform of definition.platforms) { + const key = `${eventName}:${platform}`; + if (definition.authority !== "server" && platform !== "server") { + const event = normalizeProductEventInput({ + eventId: `client:${key}`, + eventName, + occurredAt, + anonymousId: "anonymous-coverage", + sessionId: "session-coverage", + platform, + ...(Object.keys(properties).length > 0 ? { properties } : {}), + }); + expect(event, key).not.toBeNull(); + covered.add(key); + } + if (definition.authority !== "client") { + const rows = createServerProductEventRows({ + eventId: `server:${key}`, + eventName, + occurredAt, + anonymousId: "anonymous-coverage", + platform: platform as ProductEventPlatform, + userId: "user-coverage", + organizationId: "organization-coverage", + ...(Object.keys(properties).length > 0 ? { properties } : {}), + } as ServerProductEvent); + expect(rows, key).toHaveLength(1); + covered.add(key); + } + } + } + + const declared = Object.entries(EVENT_REGISTRY).flatMap( + ([eventName, definition]) => + definition.platforms.map((platform) => `${eventName}:${platform}`), + ); + expect([...covered].sort()).toEqual(declared.sort()); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-experiment.test.ts b/apps/web/__tests__/unit/product-analytics-experiment.test.ts new file mode 100644 index 00000000000..c9ddba491aa --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-experiment.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; + +const { trackEvent } = vi.hoisted(() => ({ trackEvent: vi.fn() })); + +vi.mock("@/app/utils/analytics", () => ({ trackEvent })); + +import { + assignExperimentVariant, + trackExperimentExposure, +} from "@/lib/analytics/experiment"; + +describe("experiment analytics", () => { + it("assigns the same subject and version to a stable bounded variant", () => { + const assignment = { + experimentId: "pricing_headline", + assignmentVersion: "v1", + subjectId: "visitor-1", + variants: ["control", "concise"] as const, + }; + expect(assignExperimentVariant(assignment)).toBe( + assignExperimentVariant(assignment), + ); + expect(assignment.variants).toContain(assignExperimentVariant(assignment)); + expect( + assignExperimentVariant({ ...assignment, variants: [] }), + ).toBeUndefined(); + }); + + it("emits exposure once per assignment version and variant", () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + const exposure = { + experimentId: "pricing_headline", + assignmentVersion: "v1", + variant: "control", + }; + + expect(trackExperimentExposure(exposure, storage)).toBe(true); + expect(trackExperimentExposure(exposure, storage)).toBe(false); + expect(trackEvent).toHaveBeenCalledOnce(); + expect(trackEvent).toHaveBeenCalledWith("experiment_exposed", { + experiment_id: "pricing_headline", + variant: "control", + assignment_version: "v1", + }); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-first-view.test.ts b/apps/web/__tests__/unit/product-analytics-first-view.test.ts new file mode 100644 index 00000000000..42efab10d68 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-first-view.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const where = vi.fn(); +const set = vi.fn(() => ({ where })); +const update = vi.fn(() => ({ set })); + +vi.mock("@cap/database", () => ({ db: () => ({ update }) })); +vi.mock("@cap/database/schema", () => ({ + videos: { firstExternalViewAt: "firstExternalViewAt", id: "id" }, +})); +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...conditions: unknown[]) => conditions), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), + isNull: vi.fn((field: unknown) => ({ field, isNull: true })), +})); + +describe("first external view claim", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses database timestamp precision for stable reconciliation payloads", async () => { + const { firstExternalViewTimestamp } = await import( + "@/lib/analytics/first-view" + ); + expect( + firstExternalViewTimestamp(1_753_000_000_987).getMilliseconds(), + ).toBe(0); + }); + + it("allows only the first conditional update to claim the milestone", async () => { + where.mockResolvedValueOnce([{ affectedRows: 1 }]); + where.mockResolvedValueOnce([{ affectedRows: 0 }]); + const { claimFirstExternalView } = await import( + "@/lib/analytics/first-view" + ); + const claimedAt = new Date("2026-07-31T12:00:00.000Z"); + + await expect( + claimFirstExternalView("video-1" as never, claimedAt), + ).resolves.toBe(true); + await expect( + claimFirstExternalView("video-1" as never, claimedAt), + ).resolves.toBe(false); + expect(set).toHaveBeenNthCalledWith(1, { + firstExternalViewAt: claimedAt, + }); + expect(set).toHaveBeenNthCalledWith(2, { + firstExternalViewAt: claimedAt, + }); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-platform.test.ts b/apps/web/__tests__/unit/product-analytics-platform.test.ts new file mode 100644 index 00000000000..cf4aff855e6 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-platform.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { videoAnalyticsPlatform } from "@/lib/analytics/video-platform"; + +describe("videoAnalyticsPlatform", () => { + it("preserves an explicit CLI initiating surface", () => { + expect( + videoAnalyticsPlatform({ + metadata: { initiatingPlatform: "cli" }, + source: { type: "webMP4" }, + }), + ).toBe("cli"); + }); + + it("uses bounded server-side fallbacks for historical videos", () => { + expect( + videoAnalyticsPlatform({ + metadata: {}, + source: { type: "desktopSegments" }, + }), + ).toBe("desktop"); + expect( + videoAnalyticsPlatform({ + metadata: {}, + source: { type: "webMP4" }, + }), + ).toBe("server"); + }); +}); 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..3e39984502e --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-queue.test.ts @@ -0,0 +1,498 @@ +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, + 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", + properties: { + hostname: "cap.so", + is_session_entry: true, + session_started_at: "2026-07-12T12:00:00.000Z", + }, +}); + +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); + expect(transport.mock.calls[0]?.[2]).toMatchObject({ + attempted: 2, + accepted: 0, + }); + }); + + it("counts contract rejections as observable drops", () => { + const queue = new ProductAnalyticsQueue(vi.fn()); + queue.recordContractRejection(); + expect(queue.deliverySnapshot).toMatchObject({ + dropped: 1, + contract_rejected: 1, + }); + }); + + 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); + }); + + it("keeps the browser enqueue path within its CPU budget", () => { + vi.useRealTimers(); + const samples: number[] = []; + for (let sample = 0; sample < 10; sample += 1) { + const queue = new ProductAnalyticsQueue( + () => new Promise(() => {}), + (() => + 0 as unknown as ReturnType< + typeof setTimeout + >) as unknown as typeof setTimeout, + () => {}, + ); + const startedAt = performance.now(); + for (let index = 0; index < 1_000; index += 1) { + queue.enqueue(makeEvent(index)); + } + samples.push(performance.now() - startedAt); + } + const sorted = [...samples].sort((left, right) => left - right); + const p95Ms = sorted[Math.ceil(sorted.length * 0.95) - 1] ?? Infinity; + console.info( + `Browser analytics enqueue p95: ${p95Ms.toFixed(2)}ms for 1,000 events`, + ); + expect(p95Ms).toBeLessThan(250); + }); + + it("recovers an unconfirmed in-flight batch after a page restart", async () => { + vi.setSystemTime(new Date("2026-07-12T12:01:00.000Z")); + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; + const abandonedTransport = vi.fn( + () => new Promise(() => {}), + ); + const abandonedQueue = new ProductAnalyticsQueue( + abandonedTransport, + setTimeout, + clearTimeout, + storage, + ); + abandonedQueue.enqueue({ + ...makeEvent(1), + properties: { + hostname: "cap.so", + is_session_entry: true, + session_started_at: "2026-07-12T12:00:00.000Z", + }, + }); + void abandonedQueue.flush("unload"); + + const recoveredTransport = vi + .fn() + .mockResolvedValue("success"); + const recoveredQueue = new ProductAnalyticsQueue( + recoveredTransport, + setTimeout, + clearTimeout, + storage, + ); + expect(recoveredQueue.size).toBe(1); + await vi.advanceTimersByTimeAsync(2_000); + + expect(recoveredTransport.mock.calls[0]?.[0][0]?.eventId).toBe("event-1"); + expect(recoveredQueue.deliverySnapshot).toMatchObject({ + attempted: 2, + accepted: 1, + retried: 1, + dropped: 0, + }); + }); + + it("makes blocked queue persistence observable", () => { + const storage = { + getItem: () => { + throw new Error("blocked"); + }, + removeItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }; + const queue = new ProductAnalyticsQueue( + vi.fn(), + setTimeout, + clearTimeout, + storage, + ); + queue.enqueue(makeEvent(1)); + expect(queue.deliverySnapshot.persistence_failed).toBeGreaterThan(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("regenerates a cookie identity containing personal data", () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn() }; + expect( + getOrCreateBrowserAnonymousId( + storage, + "alice@example.com", + () => "safe-id", + ), + ).toBe("safe-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("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("treats an accepted unload beacon as unconfirmed for a stable-ID retry", async () => { + const fetchImpl = vi.fn(); + const sendBeacon = vi.fn(() => true); + await expect( + sendBrowserProductAnalytics([makeEvent(1)], "unload", { + fetchImpl, + sendBeacon, + }), + ).resolves.toBe("retry"); + 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("returns per-event admission for a mixed conflict batch", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + Response.json({ + accepted: 1, + acceptedEventIds: ["event-1"], + rejectedEventIds: ["event-2"], + }), + ); + await expect( + sendBrowserProductAnalytics([makeEvent(1), makeEvent(2)], "normal", { + fetchImpl, + }), + ).resolves.toEqual({ + acceptedEventIds: ["event-1"], + rejectedEventIds: ["event-2"], + }); + }); + + it.each([ + [404, "retry"], + [410, "retry"], + [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-refresh.test.ts b/apps/web/__tests__/unit/product-analytics-refresh.test.ts new file mode 100644 index 00000000000..b593bdf9878 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-refresh.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + acquire: vi.fn(), + release: vi.fn(), + renew: vi.fn(), + start: vi.fn(), +})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ + PRODUCT_ANALYTICS_TINYBIRD_COPY_TOKEN: "copy-token", + PRODUCT_ANALYTICS_TINYBIRD_HOST: "https://staging.tinybird.test", + PRODUCT_ANALYTICS_TINYBIRD_READ_TOKEN: "read-token", + PRODUCT_ANALYTICS_TINYBIRD_SCHEDULER_TOKEN: "scheduler-token", + }), +})); + +vi.mock("@/lib/analytics/product-analytics-refresh-state", () => ({ + acquireProductAnalyticsRefreshLease: mocks.acquire, + releaseProductAnalyticsRefreshLease: mocks.release, + renewProductAnalyticsRefreshLease: mocks.renew, +})); + +vi.mock("workflow/api", () => ({ start: mocks.start })); + +const markerPayload = { + activation_markers: 1, + attribution_markers: 1, + decision_markers: 1, + experiment_markers: 1, + identity_markers: 1, + retention_markers: 1, + traffic_markers: 1, + traffic_page_markers: 1, +}; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete process.env.CRON_SECRET; + mocks.acquire.mockReset(); + mocks.release.mockReset(); + mocks.renew.mockReset(); + mocks.start.mockReset(); +}); + +describe("product analytics refresh", () => { + it("runs every decision copy sequentially at one source cutoff", async () => { + const requestedUrls: URL[] = []; + mocks.acquire.mockResolvedValue({ + ownerId: "refresh-owner-1", + sourceCutoff: "2026-07-31T12:00:00.000Z", + }); + mocks.renew.mockResolvedValue(true); + mocks.release.mockResolvedValue(undefined); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + requestedUrls.push(url); + if (url.pathname.endsWith("/copy")) { + return Response.json({ + job_id: `job-${requestedUrls.length.toString().padStart(4, "0")}`, + }); + } + if (url.pathname.startsWith("/v0/jobs/")) { + return Response.json({ status: "done" }); + } + return Response.json({ data: [markerPayload] }); + }), + ); + + const { refreshProductAnalyticsWorkflow } = await import( + "@/workflows/refresh-product-analytics" + ); + const result = await refreshProductAnalyticsWorkflow({ + scheduledAt: "2026-07-31T12:00:00.000Z", + }); + + expect(result).toMatchObject({ + refreshed: true, + sourceCutoff: "2026-07-31T12:00:00.000Z", + }); + expect(result.jobs).toHaveLength(8); + expect(mocks.renew).toHaveBeenCalledTimes(8); + expect(mocks.release).toHaveBeenCalledWith("refresh-owner-1", undefined); + for (const request of requestedUrls.filter((url) => + url.pathname.startsWith("/v0/jobs/"), + )) { + expect(request.pathname).toMatch(/^\/v0\/jobs\/[A-Za-z0-9_-]+$/); + } + const copyUrls = requestedUrls.filter((url) => + url.pathname.endsWith("/copy"), + ); + expect(copyUrls).toHaveLength(8); + expect( + copyUrls.map((url) => url.searchParams.get("source_cutoff")), + ).toEqual(Array.from({ length: 8 }, () => "2026-07-31 12:00:00.000")); + expect( + new Set(copyUrls.map((url) => url.searchParams.get("copy_run_id"))).size, + ).toBe(1); + }); + + it("records a failed lease state when a copy assertion is missing", async () => { + mocks.acquire.mockResolvedValue({ + ownerId: "refresh-owner-2", + sourceCutoff: "2026-07-31T12:00:00.000Z", + }); + mocks.renew.mockResolvedValue(true); + mocks.release.mockResolvedValue(undefined); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/copy")) { + return Response.json({ job_id: "job-missing-marker" }); + } + if (url.pathname.startsWith("/v0/jobs/")) { + return Response.json({ status: "done" }); + } + return Response.json({ data: [{}] }); + }), + ); + + const { refreshProductAnalyticsWorkflow } = await import( + "@/workflows/refresh-product-analytics" + ); + await expect( + refreshProductAnalyticsWorkflow({ + scheduledAt: "2026-07-31T12:00:00.000Z", + }), + ).rejects.toThrow("refresh marker was missing"); + expect(mocks.release).toHaveBeenCalledWith( + "refresh-owner-2", + "refresh_failed", + ); + }); + + it("does not touch Tinybird when another refresh owns the lease", async () => { + mocks.acquire.mockResolvedValue(undefined); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const { refreshProductAnalyticsWorkflow } = await import( + "@/workflows/refresh-product-analytics" + ); + + await expect( + refreshProductAnalyticsWorkflow({ + scheduledAt: "2026-07-31T12:00:00.000Z", + }), + ).resolves.toEqual({ refreshed: false, reason: "lease_unavailable" }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(mocks.release).not.toHaveBeenCalled(); + }); + + it("authenticates the cron before starting a durable workflow", async () => { + process.env.CRON_SECRET = "refresh-secret"; + mocks.start.mockResolvedValue({ runId: "refresh-run-1" }); + const { GET } = await import( + "@/app/api/cron/refresh-product-analytics/route" + ); + const unauthorized = await GET( + new Request("https://cap.test/api/cron/refresh-product-analytics"), + ); + expect(unauthorized.status).toBe(401); + + const accepted = await GET( + new Request("https://cap.test/api/cron/refresh-product-analytics", { + headers: { authorization: "Bearer refresh-secret" }, + }), + ); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toEqual({ + accepted: true, + runId: "refresh-run-1", + }); + expect(mocks.start).toHaveBeenCalledOnce(); + }); +}); 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..7c4e37849be --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-request.test.ts @@ -0,0 +1,359 @@ +import { + PRODUCT_ANALYTICS_LIMITS, + type ProductEventInput, +} from "@cap/analytics"; +import { describe, expect, it } from "vitest"; +import { + classifyAnalyticsTraffic, + getProductAnalyticsRateLimitKey, + hasExpectedBrowserAnalyticsMetadata, + isAllowedAnonymousBrowserProductEvent, + isAuthenticatedAnalyticsRequestCandidate, + normalizeGeoHeader, + normalizeProductEventBatch, + normalizeSyntheticRunId, + ProductAnalyticsRateLimiter, + shouldRejectUnresolvedAuthenticatedAnalyticsRequest, +} from "@/lib/analytics/request"; +import { getVercelDeploymentOrigins } from "@/lib/analytics/vercel-origins"; + +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", + properties: { + hostname: "cap.so", + is_session_entry: true, + session_started_at: "2026-07-12T12:00:00.000Z", + }, +}; +const now = Date.parse("2026-07-12T12:00:01.000Z"); + +describe("getVercelDeploymentOrigins", () => { + it("allows only the current Vercel deployment and branch hosts", () => { + expect( + getVercelDeploymentOrigins({ + VERCEL_URL: "cap-abc123.vercel.app", + VERCEL_BRANCH_URL: "cap-git-feature.vercel.app", + }), + ).toEqual([ + "https://cap-abc123.vercel.app", + "https://cap-git-feature.vercel.app", + ]); + }); + + it("rejects malformed and non-Vercel hosts", () => { + expect( + getVercelDeploymentOrigins({ + VERCEL_URL: "attacker.example", + VERCEL_BRANCH_URL: "attacker.example@cap.vercel.app", + }), + ).toEqual([]); + }); +}); + +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("fails closed when a supplied authenticated identity no longer resolves", () => { + expect( + shouldRejectUnresolvedAuthenticatedAnalyticsRequest({ + actorResolved: false, + authorizationCandidate: true, + hasSessionCookie: false, + }), + ).toBe(true); + expect( + shouldRejectUnresolvedAuthenticatedAnalyticsRequest({ + actorResolved: false, + authorizationCandidate: false, + hasSessionCookie: true, + }), + ).toBe(true); + expect( + shouldRejectUnresolvedAuthenticatedAnalyticsRequest({ + actorResolved: true, + authorizationCandidate: true, + hasSessionCookie: true, + }), + ).toBe(false); + expect( + shouldRejectUnresolvedAuthenticatedAnalyticsRequest({ + actorResolved: false, + authorizationCandidate: false, + hasSessionCookie: false, + }), + ).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 hashed platform-owned network identity", () => { + const first = getProductAnalyticsRateLimitKey({ + trustedNetworkProxy: true, + forwardedFor: "203.0.113.10, 10.0.0.1", + }); + expect(first).toMatch(/^network:[0-9a-f]{64}$/); + expect(first).toBe( + getProductAnalyticsRateLimitKey({ + trustedNetworkProxy: true, + forwardedFor: "203.0.113.10", + }), + ); + expect(first).not.toBe( + getProductAnalyticsRateLimitKey({ + trustedNetworkProxy: true, + forwardedFor: "203.0.113.11", + }), + ); + expect( + getProductAnalyticsRateLimitKey({ + trustedNetworkProxy: false, + forwardedFor: "203.0.113.10", + }), + ).toBeNull(); + expect( + getProductAnalyticsRateLimitKey({ + trustedNetworkProxy: true, + forwardedFor: "attacker-controlled", + }), + ).toBeNull(); + expect( + getProductAnalyticsRateLimitKey({ trustedNetworkProxy: true }), + ).toBeNull(); + }); +}); + +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(); + }); +}); + +describe("analytics traffic classification", () => { + const externalRequest = { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/138.0 Safari/537.36", + vercelEnvironment: "production" as const, + rateLimitKey: `network:${"a".repeat(64)}`, + }; + + it("accepts ordinary production browsers", () => { + expect(classifyAnalyticsTraffic(externalRequest)).toBe("external"); + }); + + it.each([ + ["synthetic", { ...externalRequest, syntheticRunId: "staging_run_123" }], + ["preview", { ...externalRequest, vercelEnvironment: "preview" as const }], + [ + "bot", + { ...externalRequest, userAgent: "Mozilla/5.0 compatible Googlebot/2.1" }, + ], + ["internal", { ...externalRequest, internalIpHashes: "a".repeat(64) }], + ] as const)( + "classifies %s traffic before business events", + (expected, input) => { + expect(classifyAnalyticsTraffic(input)).toBe(expected); + }, + ); + + it("accepts synthetic run identifiers only in preview", () => { + expect(normalizeSyntheticRunId("staging_run_123", "preview")).toBe( + "staging_run_123", + ); + expect( + normalizeSyntheticRunId("staging_run_123", "production"), + ).toBeUndefined(); + expect( + normalizeSyntheticRunId("contains spaces", "preview"), + ).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..49fae63d1db --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-scheduler.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + queue: vi.fn(async () => ({ + eventId: "checkout:cs_1", + payloadHash: "hash-1", + payloadConflict: false, + status: "started", + runId: "run-1", + })), +})); + +vi.mock("@/lib/analytics/product-event-outbox", () => ({ + queueDurableServerProductEvent: mocks.queue, +})); + +describe("analytics durable enqueue", () => { + afterEach(() => { + mocks.queue.mockClear(); + }); + + it("returns only after the workflow run is durably enqueued", async () => { + const { queueServerProductEvent } = await import("@/lib/analytics/server"); + await expect( + queueServerProductEvent({ + eventId: "checkout:cs_1", + eventName: "checkout_started", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "anonymous-1", + platform: "web", + properties: { price_id: "price_1", quantity: 1 }, + }), + ).resolves.toEqual({ + eventId: "checkout:cs_1", + payloadHash: "hash-1", + payloadConflict: false, + status: "started", + runId: "run-1", + }); + expect(mocks.queue).toHaveBeenCalledOnce(); + }); + + it("surfaces enqueue failure so a critical business request can retry", async () => { + mocks.queue.mockRejectedValueOnce(new Error("database unavailable")); + const { queueServerProductEvent } = await import("@/lib/analytics/server"); + await expect( + queueServerProductEvent({ + eventId: "signup:user-1", + eventName: "user_signed_up", + occurredAt: "2026-07-12T12:00:00.000Z", + platform: "web", + userId: "user-1", + }), + ).rejects.toThrow("database unavailable"); + }); +}); 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..95576891057 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-server.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest"; +import { + collaborationActionCreatedEvent, + firstViewReceivedEvent, + identityLinkedEvent, + shareLinkCreatedEvent, + uploadCompletedEvent, + uploadFailedEvent, + userSignedUpEvent, +} from "@/lib/analytics/business-events"; +import { createServerProductEventRows } from "@/lib/analytics/server-event"; + +describe("server product analytics", () => { + const purchaseProperties = { + payment_status: "paid", + subscription_status: "active", + quantity: 3, + is_first_purchase: true, + is_guest_checkout: false, + is_onboarding: false, + } as const; + + 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: purchaseProperties, + }); + + 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: + '{"payment_status":"paid","subscription_status":"active","quantity":3,"is_first_purchase":true,"is_guest_checkout":false,"is_onboarding":false}', + }); + }); + + it("uses an authenticated fallback identity", () => { + const [row] = createServerProductEventRows({ + eventId: "signup:user-1", + eventName: "user_signed_up", + occurredAt: "2026-07-12T12:00:00.000Z", + platform: "web", + userId: "user-1", + }); + expect(row?.anonymous_id).toBe("user:user-1"); + }); + + it("marks bounded staging events as synthetic without exposing the run ID", () => { + const [row] = createServerProductEventRows({ + _syntheticRunId: "run_staging_route_123", + eventId: "signup:user-1", + eventName: "user_signed_up", + occurredAt: "2026-07-12T12:00:00.000Z", + platform: "web", + userId: "user-1", + }); + + expect(row).toMatchObject({ + synthetic_run_id: "run_staging_route_123", + traffic_class: "synthetic", + }); + expect(row?.properties).not.toContain("run_staging_route_123"); + }); + + it("rejects an invalid synthetic staging run ID", () => { + expect( + createServerProductEventRows({ + _syntheticRunId: "contains spaces", + eventId: "signup:user-1", + eventName: "user_signed_up", + occurredAt: "2026-07-12T12:00:00.000Z", + platform: "web", + userId: "user-1", + }), + ).toEqual([]); + }); + + it("drops an event without any stable identity", () => { + expect( + createServerProductEventRows({ + eventId: "event-1", + eventName: "user_signed_up", + occurredAt: "2026-07-12T12:00:00.000Z", + platform: "web", + }), + ).toEqual([]); + }); + + it("rejects an invalid timestamp before delivery", () => { + expect( + createServerProductEventRows({ + eventId: "event-1", + eventName: "purchase_completed", + occurredAt: "invalid", + platform: "server", + userId: "user-1", + properties: purchaseProperties, + }), + ).toEqual([]); + }); + + it("rejects a property payload containing undeclared customer data", () => { + const unsafeCreate = createServerProductEventRows as unknown as ( + event: Record, + ) => unknown[]; + expect( + unsafeCreate({ + eventId: "event-1", + eventName: "user_signed_up", + occurredAt: "2026-07-12T12:00:00.000Z", + platform: "web", + userId: "user-1", + properties: { email: "private@example.com" }, + }), + ).toEqual([]); + }); + + it("rejects personal data and raw errors in declared server fields", () => { + const unsafeCreate = createServerProductEventRows as unknown as ( + event: Record, + ) => unknown[]; + expect( + unsafeCreate({ + eventId: "stripe:evt_123:purchase_completed", + eventName: "purchase_completed", + occurredAt: "2026-07-12T12:00:00.000Z", + platform: "server", + userId: "user-1", + properties: { + ...purchaseProperties, + subscription_status: "alice@example.com", + }, + }), + ).toEqual([]); + expect( + unsafeCreate({ + eventId: "loom_import:video-1:failed", + eventName: "loom_import_failed", + occurredAt: "2026-07-12T12:00:00.000Z", + platform: "server", + userId: "user-1", + properties: { + import_mode: "video", + failure_class: "Failed reading /Users/alice/private.mp4", + }, + }), + ).toEqual([]); + expect( + unsafeCreate({ + eventId: "signup:user-1", + eventName: "user_signed_up", + platform: "web", + userId: "person@example.com", + }), + ).toEqual([]); + }); + + it("builds reconciliation-compatible authoritative business facts", () => { + const facts = [ + userSignedUpEvent({ + userId: "user-1", + createdAt: "2026-07-31T10:00:00.000Z", + }), + shareLinkCreatedEvent({ + videoId: "video-1", + platform: "server", + userId: "user-1", + organizationId: "org-1", + createdAt: "2026-07-31T10:01:00.000Z", + isScreenshot: false, + sourceType: "desktopSegments", + }), + collaborationActionCreatedEvent({ + commentId: "comment-1", + userId: "user-1", + organizationId: "org-1", + createdAt: "2026-07-31T10:02:00.000Z", + action: "comment", + }), + firstViewReceivedEvent({ + videoId: "video-1", + userId: "user-1", + organizationId: "org-1", + createdAt: "2026-07-31T10:03:00.000Z", + }), + uploadCompletedEvent({ + videoId: "video-1", + platform: "cli", + userId: "user-1", + organizationId: "org-1", + createdAt: "2026-07-31T10:04:00.000Z", + }), + uploadFailedEvent({ + videoId: "video-2", + platform: "server", + userId: "user-1", + organizationId: "org-1", + createdAt: "2026-07-31T10:05:00.000Z", + failureClass: "plan", + }), + ] as const; + + for (const fact of facts) { + const first = createServerProductEventRows(fact)[0]; + const reconciled = createServerProductEventRows({ ...fact })[0]; + expect(reconciled?.event_id).toBe(first?.event_id); + expect(reconciled?.payload_hash).toBe(first?.payload_hash); + } + }); + + it("links signup attribution without changing the signup fact", () => { + const signup = createServerProductEventRows( + userSignedUpEvent({ + userId: "user-1", + createdAt: "2026-07-31T10:00:00.000Z", + }), + )[0]; + const link = createServerProductEventRows( + identityLinkedEvent({ + userId: "user-1", + organizationId: "org-1", + anonymousId: "anonymous-1", + createdAt: "2026-07-31T10:00:00.000Z", + }), + )[0]; + + expect(signup?.anonymous_id).toBe("user:user-1"); + expect(link).toMatchObject({ + event_name: "identity_linked", + anonymous_id: "anonymous-1", + user_id: "user-1", + }); + expect(link?.event_id).toMatch(/^identity_linked:user-1:[0-9a-f]{24}$/); + expect( + createServerProductEventRows( + identityLinkedEvent({ + userId: "user-1", + organizationId: "org-1", + anonymousId: "anonymous-2", + createdAt: "2026-07-31T10:00:00.000Z", + }), + )[0]?.event_id, + ).not.toBe(link?.event_id); + }); +}); 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..cef39b8a8e3 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-transport.test.ts @@ -0,0 +1,139 @@ +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", + properties: { + hostname: "cap.so", + is_session_entry: true, + session_started_at: "2026-07-12T12:00:00.000Z", + }, + }, + ], + { + 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/share-iframe-navigation.test.ts b/apps/web/__tests__/unit/share-iframe-navigation.test.ts index 298078b0e4f..f389bfe77ea 100644 --- a/apps/web/__tests__/unit/share-iframe-navigation.test.ts +++ b/apps/web/__tests__/unit/share-iframe-navigation.test.ts @@ -14,6 +14,7 @@ vi.mock("@cap/database/schema", () => ({ organizations: {} })); vi.mock("@cap/env", () => ({ buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, serverEnv: () => ({ + NEXTAUTH_SECRET: "test-secret", WEB_URL: "https://cap.so", VERCEL_URL_HOST: undefined, VERCEL_BRANCH_URL_HOST: undefined, @@ -59,6 +60,9 @@ describe("share iframe navigation", () => { ); expect(response.headers.get("cache-control")).toBe("private, no-store"); expect(response.headers.get("vary")).toBe("Sec-Fetch-Dest"); + expect(response.headers.get("set-cookie")).toContain( + "cap_analytics_browser_token=", + ); }); it("keeps normal share navigations on the share page", async () => { diff --git a/apps/web/__tests__/unit/stripe-analytics-reconciliation.test.ts b/apps/web/__tests__/unit/stripe-analytics-reconciliation.test.ts new file mode 100644 index 00000000000..695f6888e06 --- /dev/null +++ b/apps/web/__tests__/unit/stripe-analytics-reconciliation.test.ts @@ -0,0 +1,517 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + eventsList: vi.fn(), + invoicesList: vi.fn(), + retrieveInvoice: vi.fn(), + retrieveSubscription: vi.fn(), + queueDurableServerProductEvent: vi.fn(), + users: [] as Array<{ + id: string; + activeOrganizationId: string; + stripeCustomerId: string; + }>, +})); + +const dbChain = { + delete: vi.fn(), + insert: vi.fn(), + onDuplicateKeyUpdate: vi.fn(), + select: vi.fn(), + from: vi.fn(), + values: vi.fn(), + where: vi.fn(), +}; + +vi.mock("@cap/database", () => ({ db: () => dbChain })); +vi.mock("@cap/database/schema", () => ({ + comments: {}, + messengerSupportEmails: {}, + productAnalyticsReconciliationFailures: { + attemptCount: "productAnalyticsReconciliationFailures.attemptCount", + sourceHash: "productAnalyticsReconciliationFailures.sourceHash", + }, + users: { + id: "users.id", + activeOrganizationId: "users.activeOrganizationId", + stripeCustomerId: "users.stripeCustomerId", + }, + videos: {}, +})); +vi.mock("@cap/utils", () => ({ + stripe: () => ({ + events: { list: mocks.eventsList }, + invoices: { + list: mocks.invoicesList, + retrieve: mocks.retrieveInvoice, + }, + subscriptions: { retrieve: mocks.retrieveSubscription }, + }), +})); +vi.mock("drizzle-orm", () => ({ + and: (...values: unknown[]) => values, + asc: (value: unknown) => value, + eq: (left: unknown, right: unknown) => ({ left, right }), + gt: (left: unknown, right: unknown) => ({ left, right }), + gte: (left: unknown, right: unknown) => ({ left, right }), + inArray: (left: unknown, right: unknown) => ({ left, right }), + isNotNull: (value: unknown) => value, + lte: (left: unknown, right: unknown) => ({ left, right }), + notInArray: (left: unknown, right: unknown) => ({ left, right }), + or: (...values: unknown[]) => values, + sql: (...values: unknown[]) => values, +})); +vi.mock("@/lib/account-deletion-request", () => ({ + ACCOUNT_DELETION_PENDING_SUBJECT: "pending-deletion", +})); +vi.mock("@/lib/analytics/product-event-outbox", () => ({ + queueDurableServerProductEvent: mocks.queueDurableServerProductEvent, +})); + +const checkoutSession = { + id: "cs_1", + created: 1_752_537_600, + 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", + analyticsSchemaVersion: "1", + analyticsPriceId: "price_team", + analyticsQuantity: "3", + analyticsOrganizationId: "org-1", + analyticsIsFirstPurchase: "true", + }, +}; + +const subscription = { + id: "sub_1", + customer: "cus_1", + status: "active", + metadata: checkoutSession.metadata, + cancel_at_period_end: false, + ended_at: null, + items: { + data: [ + { + quantity: 3, + price: { + id: "price_team", + currency: "usd", + unit_amount: 900, + recurring: { interval: "month", interval_count: 1 }, + }, + }, + ], + }, +}; + +describe("Stripe analytics reconciliation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.users.splice(0, mocks.users.length, { + id: "user-1", + activeOrganizationId: "org-1", + stripeCustomerId: "cus_1", + }); + dbChain.select.mockReturnValue(dbChain); + dbChain.delete.mockReturnValue(dbChain); + dbChain.insert.mockReturnValue(dbChain); + dbChain.from.mockReturnValue(dbChain); + dbChain.values.mockReturnValue(dbChain); + dbChain.onDuplicateKeyUpdate.mockResolvedValue(undefined); + dbChain.where.mockImplementation(async () => [...mocks.users]); + mocks.eventsList.mockImplementation(async ({ type }: { type: string }) => ({ + data: + type === "checkout.session.completed" + ? [ + { + id: "evt_checkout", + created: 1_752_537_600, + type, + data: { object: checkoutSession }, + }, + ] + : [], + has_more: false, + })); + mocks.retrieveSubscription.mockResolvedValue(subscription); + mocks.invoicesList.mockResolvedValue({ data: [], has_more: false }); + mocks.queueDurableServerProductEvent.mockResolvedValue({ + deliveryKey: "delivery-key", + status: "started", + }); + }); + + it("rebuilds Stripe checkout facts with the original event identity", async () => { + const { loadStripeAnalyticsReconciliationEventsStep } = await import( + "@/workflows/reconcile-product-analytics" + ); + + const result = await loadStripeAnalyticsReconciliationEventsStep({ + scheduledAt: "2025-07-16T00:00:00.000Z", + lookbackHours: 48, + }); + + expect(result).toEqual({ + failed: 0, + legacyStripeEventsSkipped: 0, + reconciled: 1, + }); + expect(mocks.queueDurableServerProductEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "stripe:evt_checkout:purchase_completed", + eventName: "purchase_completed", + occurredAt: "2025-07-15T00:00:00.000Z", + userId: "user-1", + organizationId: "org-1", + properties: expect.objectContaining({ + amount_total_minor: 2700, + currency: "usd", + is_first_purchase: true, + }), + }), + ); + expect(mocks.eventsList.mock.calls.map(([input]) => input.type)).toEqual([ + "checkout.session.created", + "checkout.session.completed", + "checkout.session.async_payment_succeeded", + "charge.refunded", + "invoice.paid", + "invoice.payment_failed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + ]); + }); + + it("rebuilds versioned checkout metadata and counts legacy sessions", async () => { + mocks.users[0] = { + id: "user-1", + activeOrganizationId: "org-2", + stripeCustomerId: "cus_1", + }; + mocks.eventsList.mockImplementation(async ({ type }: { type: string }) => ({ + data: + type === "checkout.session.created" + ? [ + { + id: "evt_legacy", + created: 1_752_537_500, + type, + data: { + object: { + ...checkoutSession, + id: "cs_legacy", + metadata: { platform: "web" }, + }, + }, + }, + { + id: "evt_created", + created: 1_752_537_600, + type, + data: { object: checkoutSession }, + }, + ] + : [], + has_more: false, + })); + const { loadStripeAnalyticsReconciliationEventsStep } = await import( + "@/workflows/reconcile-product-analytics" + ); + + const result = await loadStripeAnalyticsReconciliationEventsStep({ + scheduledAt: "2025-07-16T00:00:00.000Z", + lookbackHours: 48, + }); + + expect(result).toEqual({ + failed: 0, + legacyStripeEventsSkipped: 1, + reconciled: 1, + }); + expect(mocks.queueDurableServerProductEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "checkout:cs_1", + eventName: "checkout_started", + occurredAt: "2025-07-15T00:00:00.000Z", + organizationId: "org-1", + properties: expect.objectContaining({ + price_id: "price_team", + quantity: 3, + }), + }), + ); + }); + + it("quarantines a paid checkout that cannot be tied to a Cap user", async () => { + mocks.users.splice(0); + const { loadStripeAnalyticsReconciliationEventsStep } = await import( + "@/workflows/reconcile-product-analytics" + ); + + await expect( + loadStripeAnalyticsReconciliationEventsStep({ + scheduledAt: "2025-07-16T00:00:00.000Z", + lookbackHours: 48, + }), + ).resolves.toEqual({ + failed: 1, + legacyStripeEventsSkipped: 0, + reconciled: 0, + }); + expect(dbChain.insert).toHaveBeenCalled(); + }); + + it("rebuilds a trial from the immutable subscription-created snapshot", async () => { + mocks.eventsList.mockImplementation(async ({ type }: { type: string }) => ({ + data: + type === "customer.subscription.created" + ? [ + { + id: "evt_trial", + created: 1_752_537_600, + type, + data: { + object: { + ...subscription, + customer: "cus_1", + status: "trialing", + trial_end: 1_753_142_400, + metadata: checkoutSession.metadata, + }, + }, + }, + ] + : [], + has_more: false, + })); + const { loadStripeAnalyticsReconciliationEventsStep } = await import( + "@/workflows/reconcile-product-analytics" + ); + + const result = await loadStripeAnalyticsReconciliationEventsStep({ + scheduledAt: "2025-07-16T00:00:00.000Z", + lookbackHours: 48, + }); + + expect(result).toEqual({ + failed: 0, + legacyStripeEventsSkipped: 0, + reconciled: 1, + }); + expect(mocks.queueDurableServerProductEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "stripe:evt_trial:trial_started", + eventName: "trial_started", + occurredAt: "2025-07-15T00:00:00.000Z", + userId: "user-1", + organizationId: "org-1", + }), + ); + }); + + it("rebuilds the first settled post-trial invoice as a purchase", async () => { + const invoice = { + id: "in_first_paid", + created: 1_752_537_600, + customer: "cus_1", + subscription: "sub_1", + billing_reason: "subscription_cycle", + amount_paid: 2_700, + amount_due: 2_700, + attempt_count: 1, + subtotal: 3_000, + currency: "usd", + total_discount_amounts: [{ amount: 300 }], + subscription_details: { metadata: subscription.metadata }, + lines: { + data: [ + { + quantity: 3, + metadata: subscription.metadata, + price: subscription.items.data[0]?.price, + }, + ], + }, + }; + mocks.eventsList.mockImplementation(async ({ type }: { type: string }) => ({ + data: + type === "invoice.paid" + ? [ + { + id: "evt_first_paid", + created: 1_752_537_600, + type, + data: { object: invoice }, + }, + ] + : [], + has_more: false, + })); + const { loadStripeAnalyticsReconciliationEventsStep } = await import( + "@/workflows/reconcile-product-analytics" + ); + + const result = await loadStripeAnalyticsReconciliationEventsStep({ + scheduledAt: "2025-07-16T00:00:00.000Z", + lookbackHours: 48, + }); + + expect(result).toEqual({ + failed: 0, + legacyStripeEventsSkipped: 0, + reconciled: 1, + }); + expect(mocks.queueDurableServerProductEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "stripe:evt_first_paid:purchase_completed", + eventName: "purchase_completed", + platform: "web", + organizationId: "org-1", + properties: expect.objectContaining({ + amount_total_minor: 2_700, + is_first_purchase: true, + }), + }), + ); + }); + + it("reconciles more than 5,000 Stripe facts without a hard row cap", async () => { + const total = 5_101; + mocks.eventsList.mockImplementation( + async ({ + type, + starting_after: startingAfter, + }: { + type: string; + starting_after?: string; + }) => { + if (type !== "checkout.session.created") { + return { data: [], has_more: false }; + } + const offset = startingAfter + ? Number(startingAfter.split("_").at(-1)) + 1 + : 0; + const count = Math.min(100, total - offset); + return { + data: Array.from({ length: count }, (_, index) => { + const sequence = offset + index; + return { + id: `evt_created_${sequence}`, + created: 1_752_537_600, + type, + data: { + object: { + ...checkoutSession, + id: `cs_created_${sequence}`, + }, + }, + }; + }), + has_more: offset + count < total, + }; + }, + ); + const { loadStripeAnalyticsReconciliationEventsStep } = await import( + "@/workflows/reconcile-product-analytics" + ); + + await expect( + loadStripeAnalyticsReconciliationEventsStep({ + scheduledAt: "2025-07-16T00:00:00.000Z", + lookbackHours: 48, + }), + ).resolves.toEqual({ + failed: 0, + legacyStripeEventsSkipped: 0, + reconciled: total, + }); + expect(mocks.queueDurableServerProductEvent).toHaveBeenCalledTimes(total); + }); + + it("resumes from the last durable Stripe page after a later page fails", async () => { + let secondPageAttempts = 0; + mocks.eventsList.mockImplementation( + async ({ + starting_after: startingAfter, + type, + }: { + starting_after?: string; + type: string; + }) => { + if (type !== "checkout.session.completed") { + return { data: [], has_more: false }; + } + if (!startingAfter) { + return { + data: [ + { + id: "evt_page_1", + created: 1_752_537_600, + type, + data: { object: checkoutSession }, + }, + ], + has_more: true, + }; + } + secondPageAttempts += 1; + if (secondPageAttempts === 1) + throw new Error("temporary Stripe outage"); + return { + data: [ + { + id: "evt_page_2", + created: 1_752_537_601, + type, + data: { + object: { ...checkoutSession, id: "cs_page_2" }, + }, + }, + ], + has_more: false, + }; + }, + ); + const { loadStripeAnalyticsReconciliationPageStep } = await import( + "@/workflows/reconcile-product-analytics" + ); + const input = { + scheduledAt: "2025-07-16T00:00:00.000Z", + lookbackHours: 48, + type: "checkout.session.completed" as const, + }; + const firstPage = await loadStripeAnalyticsReconciliationPageStep(input); + expect(firstPage.nextStartingAfter).toBe("evt_page_1"); + await expect( + loadStripeAnalyticsReconciliationPageStep({ + ...input, + startingAfter: firstPage.nextStartingAfter, + }), + ).rejects.toThrow("temporary Stripe outage"); + await expect( + loadStripeAnalyticsReconciliationPageStep({ + ...input, + startingAfter: firstPage.nextStartingAfter, + }), + ).resolves.toEqual({ + failed: 0, + legacyStripeEventsSkipped: 0, + nextStartingAfter: undefined, + reconciled: 1, + }); + expect( + mocks.eventsList.mock.calls.filter( + ([request]) => request.starting_after === "evt_page_1", + ), + ).toHaveLength(2); + expect(mocks.queueDurableServerProductEvent).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..a96dd65cf97 --- /dev/null +++ b/apps/web/__tests__/unit/subscription-analytics-webhook.test.ts @@ -0,0 +1,528 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + product: vi.fn(), + constructEvent: vi.fn(), + retrieveCustomer: vi.fn(), + retrieveInvoice: vi.fn(), + retrieveSubscription: vi.fn(), + listInvoices: vi.fn(), + listSubscriptions: 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", () => ({ + queueServerProductEvent: 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: mocks.listSubscriptions, + }, + invoices: { + retrieve: mocks.retrieveInvoice, + list: mocks.listInvoices, + }, + }), +})); +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", + customer: "cus_1", + status: "active", + metadata: { + platform: "web", + analyticsAnonymousId: "anonymous-1", + analyticsSchemaVersion: "1", + analyticsPriceId: "price_team", + analyticsQuantity: "3", + analyticsOrganizationId: "org-immutable", + analyticsIsFirstPurchase: "true", + }, + cancel_at_period_end: false, + ended_at: null, + items: { + data: [ + { + quantity: 3, + price: { + id: "price_team", + currency: "usd", + unit_amount: 900, + recurring: { interval: "month", interval_count: 1 }, + }, + }, + ], + }, +}; + +const invoice = { + id: "in_1", + created: 1_752_537_600, + customer: "cus_1", + subscription: "sub_1", + billing_reason: "subscription_cycle", + amount_paid: 2_700, + amount_due: 2_700, + attempt_count: 1, + subtotal: 3_000, + currency: "usd", + total_discount_amounts: [{ amount: 300 }], + subscription_details: { metadata: subscription.metadata }, + lines: { + data: [ + { + subscription: "sub_1", + quantity: 3, + metadata: subscription.metadata, + price: subscription.items.data[0]?.price, + }, + ], + }, +}; + +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", + analyticsSchemaVersion: "1", + analyticsPriceId: "price_team", + analyticsQuantity: "3", + analyticsOrganizationId: "org-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, object: unknown) { + return { + id: `evt_${type}`, + created: 1_752_537_600, + type, + data: { object }, + }; +} + +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); + mocks.retrieveInvoice.mockResolvedValue(invoice); + mocks.listInvoices.mockResolvedValue({ data: [], has_more: false }); + mocks.listSubscriptions.mockResolvedValue({ data: [subscription] }); + POST = (await import("@/app/api/webhooks/stripe/route")).POST; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + 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", + subscription_status: "paid_checkout", + amount_total_minor: 2700, + currency: "usd", + price_id: "price_team", + 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 substitute a mutable organization into immutable checkout metadata", async () => { + const metadata = session().metadata; + mocks.constructEvent.mockReturnValue( + event( + "checkout.session.completed", + session({ + metadata: { + ...metadata, + analyticsOrganizationId: undefined, + }, + }), + ), + ); + + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: undefined }), + ); + }); + + it("skips legacy checkouts without immutable analytics metadata", async () => { + mocks.constructEvent.mockReturnValue( + event( + "checkout.session.completed", + session({ metadata: { platform: "web" } }), + ), + ); + + expect((await POST(request())).status).toBe(200); + expect(mocks.product).not.toHaveBeenCalled(); + }); + + 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("replays the same Stripe event as the same decision event", async () => { + mocks.constructEvent.mockReturnValue( + event("checkout.session.completed", session()), + ); + await POST(request()); + await POST(request()); + + expect(mocks.product).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + eventId: "stripe:evt_checkout.session.completed:purchase_completed", + }), + ); + expect(mocks.product).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + eventId: "stripe:evt_checkout.session.completed:purchase_completed", + }), + ); + }); + + it("returns a retryable failure when checkout identity is not available", async () => { + vi.useFakeTimers(); + dbChain.limit.mockResolvedValue([]); + mocks.constructEvent.mockReturnValue( + event("checkout.session.completed", session()), + ); + + const responsePromise = POST(request()); + await vi.runAllTimersAsync(); + const response = await responsePromise; + + expect(response.status).toBe(503); + expect(response.headers.get("Retry-After")).toBe("60"); + expect(mocks.product).not.toHaveBeenCalled(); + }); + + it("does not emit an unattributed asynchronous purchase", async () => { + dbChain.limit.mockResolvedValue([]); + mocks.constructEvent.mockReturnValue( + event("checkout.session.async_payment_succeeded", session()), + ); + + expect((await POST(request())).status).toBe(503); + expect(mocks.product).not.toHaveBeenCalled(); + }); + + it("retries subscription changes until identity is available", async () => { + vi.useFakeTimers(); + dbChain.limit.mockResolvedValue([]); + mocks.constructEvent.mockReturnValue( + event("customer.subscription.updated", { + ...subscription, + customer: "cus_1", + }), + ); + + const responsePromise = POST(request()); + await vi.runAllTimersAsync(); + + expect((await responsePromise).status).toBe(503); + expect(mocks.product).not.toHaveBeenCalled(); + }); + + it("records a no-payment trial without counting a purchase", async () => { + mocks.constructEvent.mockReturnValue( + event("customer.subscription.created", { + ...subscription, + customer: "cus_1", + status: "trialing", + trial_end: 1_753_142_400, + metadata: session().metadata, + }), + ); + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "stripe:evt_customer.subscription.created:trial_started", + eventName: "trial_started", + properties: expect.objectContaining({ + subscription_status: "trialing", + }), + }), + ); + expect(mocks.product).not.toHaveBeenCalledWith( + expect.objectContaining({ eventName: "purchase_completed" }), + ); + }); + + it("records only the incremental amount for partial refunds", async () => { + mocks.constructEvent.mockReturnValue({ + id: "evt_refund_2", + created: 1_752_537_600, + type: "charge.refunded", + data: { + object: { + id: "ch_1", + customer: "cus_1", + invoice: "in_1", + amount_refunded: 500, + currency: "usd", + refunded: false, + }, + previous_attributes: { amount_refunded: 200 }, + }, + }); + + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "stripe:evt_refund_2:subscription_refunded", + eventName: "subscription_refunded", + properties: { + amount_refunded_minor: 300, + currency: "usd", + price_id: "price_team", + fully_refunded: false, + }, + }), + ); + }); + + it("counts the first positive post-trial invoice as the purchase", async () => { + mocks.retrieveSubscription.mockResolvedValue({ + ...subscription, + metadata: { + ...subscription.metadata, + analyticsOrganizationId: "org-mutated", + }, + }); + mocks.constructEvent.mockReturnValue(event("invoice.paid", invoice)); + + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "stripe:evt_invoice.paid:purchase_completed", + eventName: "purchase_completed", + platform: "web", + organizationId: "org-immutable", + properties: expect.objectContaining({ + amount_total_minor: 2_700, + is_first_purchase: true, + }), + }), + ); + expect(mocks.product).not.toHaveBeenCalledWith( + expect.objectContaining({ eventName: "subscription_renewed" }), + ); + expect(mocks.retrieveSubscription).not.toHaveBeenCalled(); + }); + + it("counts a later positive subscription invoice as a renewal", async () => { + mocks.listInvoices.mockResolvedValue({ + data: [{ id: "in_prior", amount_paid: 2_700 }], + has_more: false, + }); + mocks.constructEvent.mockReturnValue(event("invoice.paid", invoice)); + + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "stripe:evt_invoice.paid:subscription_renewed", + eventName: "subscription_renewed", + organizationId: "org-immutable", + }), + ); + expect(mocks.product).not.toHaveBeenCalledWith( + expect.objectContaining({ eventName: "purchase_completed" }), + ); + }); + + it("records provider-authoritative failed collection attempts", async () => { + mocks.constructEvent.mockReturnValue( + event("invoice.payment_failed", { + ...invoice, + amount_paid: 0, + amount_due: 2_700, + attempt_count: 2, + }), + ); + + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: + "stripe:evt_invoice.payment_failed:subscription_payment_failed", + eventName: "subscription_payment_failed", + organizationId: "org-immutable", + properties: expect.objectContaining({ + amount_due_minor: 2_700, + attempt_count: 2, + }), + }), + ); + }); + + it("emits both plan and seat changes from one Stripe update", async () => { + mocks.constructEvent.mockReturnValue({ + ...event("customer.subscription.updated", subscription), + data: { + object: subscription, + previous_attributes: { + items: { + data: [ + { + quantity: 1, + price: { id: "price_pro" }, + }, + ], + }, + }, + }, + }); + + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: + "stripe:evt_customer.subscription.updated:subscription_changed:plan", + properties: expect.objectContaining({ change_kind: "plan" }), + }), + ); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: + "stripe:evt_customer.subscription.updated:subscription_changed:seats", + properties: expect.objectContaining({ change_kind: "seats" }), + }), + ); + }); +}); diff --git a/apps/web/__tests__/unit/update-seat-quantity.test.ts b/apps/web/__tests__/unit/update-seat-quantity.test.ts index 838531a96cf..f6e3051a81f 100644 --- a/apps/web/__tests__/unit/update-seat-quantity.test.ts +++ b/apps/web/__tests__/unit/update-seat-quantity.test.ts @@ -8,6 +8,7 @@ const mockDb = { where: vi.fn(), limit: vi.fn(), set: vi.fn(), + transaction: vi.fn(), }; const mockStripe = { @@ -17,6 +18,9 @@ const mockStripe = { }, }; +const persistProductAnalyticsEvent = vi.fn(); +const attemptProductAnalyticsOutboxDelivery = vi.fn(); + vi.mock("@cap/database", () => ({ db: () => mockDb, })); @@ -52,6 +56,11 @@ vi.mock("@cap/utils", () => ({ stripe: () => mockStripe, })); +vi.mock("@/lib/analytics/product-event-outbox", () => ({ + attemptProductAnalyticsOutboxDelivery, + persistProductAnalyticsEvent, +})); + vi.mock("drizzle-orm", () => ({ eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), })); @@ -74,13 +83,18 @@ function resetMockDb() { mockDb.where.mockReturnValue(mockDb); mockDb.limit.mockResolvedValue([]); mockDb.set.mockReturnValue(mockDb); + mockDb.transaction.mockImplementation( + (callback: (tx: typeof mockDb) => Promise) => callback(mockDb), + ); } function mockSeatLookup({ currentQuantity, + localQuantity = currentQuantity, proSeatsUsed = 1, }: { currentQuantity: number; + localQuantity?: number; proSeatsUsed?: number; }) { mockGetCurrentUser.mockResolvedValue({ @@ -93,7 +107,7 @@ function mockSeatLookup({ { stripeCustomerId: "cus_1", stripeSubscriptionId: "sub_1", - inviteQuota: currentQuantity, + inviteQuota: localQuantity, }, ]); mockDb.where @@ -110,7 +124,18 @@ function mockSeatLookup({ mockStripe.subscriptions.retrieve.mockResolvedValue({ id: "sub_1", items: { - data: [{ id: "si_1", quantity: currentQuantity }], + data: [ + { + id: "si_1", + quantity: currentQuantity, + price: { + id: "price_1", + unit_amount: 1200, + currency: "usd", + recurring: { interval: "month" }, + }, + }, + ], }, }); } @@ -140,6 +165,21 @@ describe("updateSeatQuantity", () => { proration_behavior: "always_invoice", }); expect(mockDb.set).toHaveBeenCalledWith({ inviteQuota: 2 }); + expect(persistProductAnalyticsEvent).toHaveBeenCalledWith( + mockDb, + expect.objectContaining({ + eventName: "seat_quantity_changed", + occurredAt: expect.any(String), + properties: expect.objectContaining({ + previous_quantity: 1, + new_quantity: 2, + price_id: "price_1", + }), + }), + ); + expect(attemptProductAnalyticsOutboxDelivery).toHaveBeenCalledWith( + expect.stringContaining("seat_quantity:sub_1:"), + ); }); it("does not store added seats when Stripe leaves the subscription update pending", async () => { @@ -171,4 +211,25 @@ describe("updateSeatQuantity", () => { proration_behavior: "create_prorations", }); }); + + it("repairs a local write after Stripe already accepted the quantity", async () => { + mockSeatLookup({ currentQuantity: 2, localQuantity: 1 }); + const { updateSeatQuantity } = await import( + "@/actions/organization/update-seat-quantity" + ); + + await updateSeatQuantity("org-1" as never, 2); + + expect(mockStripe.subscriptions.update).not.toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalledWith({ inviteQuota: 2 }); + expect(persistProductAnalyticsEvent).toHaveBeenCalledWith( + mockDb, + expect.objectContaining({ + properties: expect.objectContaining({ + previous_quantity: 1, + new_quantity: 2, + }), + }), + ); + }); }); diff --git a/apps/web/actions/analytics/track-user-signed-up.ts b/apps/web/actions/analytics/track-user-signed-up.ts index 2ffb33c6f0a..8522d14c8fa 100644 --- a/apps/web/actions/analytics/track-user-signed-up.ts +++ b/apps/web/actions/analytics/track-user-signed-up.ts @@ -1,9 +1,17 @@ "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 { + identityLinkedEvent, + userSignedUpEvent, +} from "@/lib/analytics/business-events"; +import { queueServerProductEvent } from "@/lib/analytics/server"; +import { normalizeServerIdentifier } from "@/lib/analytics/server-event"; const SIGNUP_TRACKING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; @@ -15,6 +23,7 @@ type UserPreferences = { pauseReactions: boolean; }; trackedEvents?: { + product_user_signed_up?: boolean; user_signed_up?: boolean; }; } | null; @@ -53,10 +62,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 +75,46 @@ export async function checkAndMarkUserSignedUpTracked(): Promise<{ return { shouldTrack: false }; } + if (!productAlreadyTracked) { + const analyticsAnonymousId = normalizeServerIdentifier( + (await cookies()).get(PRODUCT_ANALYTICS_ANONYMOUS_ID_COOKIE)?.value, + ); + try { + const createdAt = new Date(createdAtTime); + await queueServerProductEvent( + userSignedUpEvent({ + userId: currentUser.id, + createdAt, + }), + ); + if (analyticsAnonymousId) { + await queueServerProductEvent( + identityLinkedEvent({ + userId: currentUser.id, + organizationId: currentUser.activeOrganizationId, + anonymousId: analyticsAnonymousId, + createdAt, + }), + ); + } + + 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 enqueue 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..48fa6cb16d1 100644 --- a/apps/web/actions/organization/send-invites.ts +++ b/apps/web/actions/organization/send-invites.ts @@ -2,8 +2,6 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { sendEmail } from "@cap/database/emails/config"; -import { OrganizationInvite } from "@cap/database/emails/organization-invite"; import { nanoId } from "@cap/database/helpers"; import { organizationInvites, @@ -11,10 +9,10 @@ import { organizations, users, } from "@cap/database/schema"; -import { serverEnv } from "@cap/env"; import type { Organisation } from "@cap/web-domain"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, or, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { deliverOrganizationInvite } from "@/lib/organization-invite-delivery"; import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; import { type AssignableOrganizationRole, @@ -119,7 +117,10 @@ export async function sendOrganizationInvites( .where( and( eq(organizationInvites.organizationId, organizationId), - inArray(organizationInvites.invitedEmail, validEmails), + or( + inArray(organizationInvites.invitedEmailNormalized, validEmails), + inArray(organizationInvites.invitedEmail, validEmails), + ), ), ), tx @@ -148,69 +149,53 @@ export async function sendOrganizationInvites( !existingMemberEmails.has(invite.email), ); + const createdAt = new Date(); const records = invitesToSend.map((invite) => ({ id: nanoId(), email: invite.email, role: invite.role, + createdAt, })); if (records.length > 0) { - await tx.insert(organizationInvites).values( - records.map((r) => ({ - id: r.id, - organizationId: organizationId, - invitedEmail: r.email, - invitedByUserId: user.id, - role: r.role, - })), - ); + await tx + .insert(organizationInvites) + .values( + records.map((r) => ({ + id: r.id, + organizationId: organizationId, + invitedEmail: r.email, + invitedEmailNormalized: r.email, + invitedByUserId: user.id, + role: r.role, + emailDeliveryState: "pending" as const, + emailDeliveryNextAttemptAt: r.createdAt, + createdAt: r.createdAt, + })), + ) + .onDuplicateKeyUpdate({ + set: { + invitedEmailNormalized: sql`${organizationInvites.invitedEmailNormalized}`, + }, + }); } return records; }); const emailResults = await Promise.allSettled( - inviteRecords.map((record) => { - const inviteUrl = `${serverEnv().WEB_URL}/invite/${record.id}`; - return sendEmail({ - email: record.email, - subject: `Invitation to join ${organization.name} on Cap`, - react: OrganizationInvite({ - email: record.email, - url: inviteUrl, - organizationName: organization.name, - }), - }); - }), + inviteRecords.map((record) => deliverOrganizationInvite(record.id)), ); - const failedInvites = inviteRecords.filter( - (_, i) => emailResults[i]?.status === "rejected", - ); + const failedInvites = inviteRecords.filter((_, i) => { + const result = emailResults[i]; + return ( + result?.status === "rejected" || + (result?.status === "fulfilled" && result.value.status === "deferred") + ); + }); const failedEmails = failedInvites.map((r) => r.email); - if (failedInvites.length > 0) { - try { - await db() - .delete(organizationInvites) - .where( - inArray( - organizationInvites.id, - failedInvites.map((r) => r.id), - ), - ); - } catch (cleanupError) { - console.error( - "Failed to clean up invite records after email delivery failure:", - { - failedInviteIds: failedInvites.map((r) => r.id), - failedEmails, - error: cleanupError, - }, - ); - } - } - 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..f120fd8bcfd 100644 --- a/apps/web/actions/organization/update-seat-quantity.ts +++ b/apps/web/actions/organization/update-seat-quantity.ts @@ -11,6 +11,10 @@ import { stripe } from "@cap/utils"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + attemptProductAnalyticsOutboxDelivery, + persistProductAnalyticsEvent, +} from "@/lib/analytics/product-event-outbox"; import { calculateProSeats } from "@/utils/organization"; async function getOwnerSubscription( @@ -151,9 +155,13 @@ export async function updateSeatQuantity( newQuantity: number, ) { validateQuantity(newQuantity); - const { subscription, subscriptionItem, proSeatsUsed, user } = + const { owner, subscription, subscriptionItem, proSeatsUsed, user } = await getOwnerSubscription(organizationId); const currentQuantity = subscriptionItem.quantity ?? 1; + const localQuantity = owner.inviteQuota ?? 1; + if (newQuantity === currentQuantity && newQuantity === localQuantity) { + return { success: true, newQuantity }; + } if (newQuantity < proSeatsUsed) { throw new Error( @@ -161,24 +169,27 @@ export async function updateSeatQuantity( ); } - const isSeatIncrease = newQuantity > currentQuantity; - const updatedSubscription = await stripe().subscriptions.update( - subscription.id, - { - items: [ - { - id: subscriptionItem.id, - quantity: newQuantity, - }, - ], - proration_behavior: isSeatIncrease - ? "always_invoice" - : "create_prorations", - ...(isSeatIncrease - ? { payment_behavior: "pending_if_incomplete" as const } - : {}), - }, - ); + const previousQuantity = + newQuantity === currentQuantity ? localQuantity : currentQuantity; + const isSeatIncrease = newQuantity > previousQuantity; + const updatedSubscription = + newQuantity === currentQuantity + ? subscription + : await stripe().subscriptions.update(subscription.id, { + items: [ + { + id: subscriptionItem.id, + quantity: newQuantity, + }, + ], + proration_behavior: isSeatIncrease + ? "always_invoice" + : "create_prorations", + ...(isSeatIncrease + ? { payment_behavior: "pending_if_incomplete" as const } + : {}), + }); + const changedAt = new Date().toISOString(); if (isSeatIncrease && updatedSubscription.pending_update) { throw new Error( @@ -186,11 +197,36 @@ export async function updateSeatQuantity( ); } + const latestInvoice = + typeof updatedSubscription.latest_invoice === "string" + ? updatedSubscription.latest_invoice + : updatedSubscription.latest_invoice?.id; + const eventId = `seat_quantity:${subscription.id}:${latestInvoice ?? updatedSubscription.current_period_start}:${newQuantity}`; try { - await db() - .update(users) - .set({ inviteQuota: newQuantity }) - .where(eq(users.id, user.id)); + await db().transaction(async (tx) => { + await tx + .update(users) + .set({ inviteQuota: newQuantity }) + .where(eq(users.id, user.id)); + await persistProductAnalyticsEvent(tx, { + eventId, + eventName: "seat_quantity_changed", + occurredAt: changedAt, + platform: "web", + userId: user.id, + organizationId, + properties: { + previous_quantity: previousQuantity, + new_quantity: newQuantity, + quantity_delta: newQuantity - previousQuantity, + 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, + }, + }); + }); } catch (dbError) { console.error( "CRITICAL: Stripe updated to quantity", @@ -205,6 +241,7 @@ export async function updateSeatQuantity( } revalidatePath("/dashboard/settings/organization"); + await attemptProductAnalyticsOutboxDelivery(eventId); return { success: true, newQuantity }; } diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index 6b7814a466c..f825e74c046 100644 --- a/apps/web/actions/videos/new-comment.ts +++ b/apps/web/actions/videos/new-comment.ts @@ -3,10 +3,13 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; -import { comments } from "@cap/database/schema"; +import { comments, videos } from "@cap/database/schema"; import type { ImageUpload } from "@cap/web-domain"; import { Comment, type Video } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { collaborationActionCreatedEvent } from "@/lib/analytics/business-events"; +import { queueServerProductEvent } from "@/lib/analytics/server"; import { createNotification } from "@/lib/Notification"; export async function newComment(data: { @@ -37,6 +40,14 @@ export async function newComment(data: { if (!content || !videoId) { throw new Error("Content and videoId are required"); } + const [commentVideo] = await db() + .select({ organizationId: videos.orgId }) + .from(videos) + .where(eq(videos.id, videoId)) + .limit(1); + if (!commentVideo) { + throw new Error("Video not found"); + } const id = Comment.CommentId.make(nanoId()); const newComment = { @@ -52,6 +63,17 @@ export async function newComment(data: { }; await db().insert(comments).values(newComment); + await queueServerProductEvent( + collaborationActionCreatedEvent({ + commentId: id, + userId: user.id, + organizationId: commentVideo.organizationId, + createdAt: newComment.createdAt, + action: conditionalType, + }), + ).catch(() => { + console.error("Failed to enqueue product analytics collaboration event"); + }); try { await createNotification({ diff --git a/apps/web/app/(org)/auth-email.ts b/apps/web/app/(org)/auth-email.ts index 315ad04642c..39b82c878d6 100644 --- a/apps/web/app/(org)/auth-email.ts +++ b/apps/web/app/(org)/auth-email.ts @@ -57,7 +57,6 @@ export async function requestEmailCode({ method: "email", is_signup: isSignup, auth_surface: authSurface, - email_domain: normalizedEmail.split("@").at(1), }); return normalizedEmail; diff --git a/apps/web/app/(org)/onboarding/components/Bottom.tsx b/apps/web/app/(org)/onboarding/components/Bottom.tsx index 6e3070cb8e1..e4f147e8ebd 100644 --- a/apps/web/app/(org)/onboarding/components/Bottom.tsx +++ b/apps/web/app/(org)/onboarding/components/Bottom.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { signOut } from "next-auth/react"; import { startTransition } from "react"; import { toast } from "sonner"; +import { trackEvent } from "@/app/utils/analytics"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; export const Bottom = () => { @@ -19,6 +20,10 @@ export const Bottom = () => { data: undefined, }), onSuccess: () => { + trackEvent("onboarding_milestone_completed", { + milestone: "skip_all", + skipped: true, + }); startTransition(() => { router.push("/dashboard/caps"); router.refresh(); diff --git a/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx b/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx index 45dec1e3260..d0149a9ded3 100644 --- a/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx +++ b/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx @@ -4,6 +4,7 @@ import { Button } from "@cap/ui"; import { useRouter } from "next/navigation"; import { startTransition, useState } from "react"; import { toast } from "sonner"; +import { trackEvent } from "@/app/utils/analytics"; import { UpgradeModal } from "@/components/UpgradeModal"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { Base } from "./Base"; @@ -20,6 +21,10 @@ export function CustomDomainPage() { data: undefined, }), onSuccess: (_, redirect) => { + trackEvent("onboarding_milestone_completed", { + milestone: "custom_domain", + skipped: !redirect, + }); startTransition(() => { if (redirect) { router.push("/onboarding/invite-team"); diff --git a/apps/web/app/(org)/onboarding/components/DownloadPage.tsx b/apps/web/app/(org)/onboarding/components/DownloadPage.tsx index f305550cef5..454f2c463a8 100644 --- a/apps/web/app/(org)/onboarding/components/DownloadPage.tsx +++ b/apps/web/app/(org)/onboarding/components/DownloadPage.tsx @@ -4,6 +4,7 @@ import { Button, LogoBadge } from "@cap/ui"; import { useDetectPlatform } from "hooks/useDetectPlatform"; import { Clapperboard, Zap } from "lucide-react"; import { useRouter } from "next/navigation"; +import { trackEvent } from "@/app/utils/analytics"; import { getDownloadButtonText, getDownloadUrl, @@ -71,6 +72,12 @@ export function DownloadPage() { size="lg" href={getDownloadUrl(platform, isIntel)} className="hidden justify-center items-center py-6 font-medium text-white lg:flex" + onClick={() => + trackEvent("onboarding_milestone_completed", { + milestone: "download", + skipped: false, + }) + } > {!loading && getPlatformIcon(platform)} {getDownloadButtonText(platform, loading, isIntel)} diff --git a/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx b/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx index 68c6ba3ade1..296827b6dd5 100644 --- a/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx +++ b/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx @@ -10,6 +10,7 @@ import { useRouter } from "next/navigation"; import { type MouseEvent, startTransition, useId, useState } from "react"; import { toast } from "sonner"; import { useStripeContext } from "@/app/Layout/StripeContext"; +import { trackEvent } from "@/app/utils/analytics"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { homepageCopy } from "../../../../data/homepage-copy"; import { Base } from "./Base"; @@ -45,6 +46,10 @@ export function InviteTeamPage() { data: undefined, }), onSuccess: (_, redirect: boolean) => { + trackEvent("onboarding_milestone_completed", { + milestone: "invite_team", + skipped: !redirect, + }); startTransition(() => { if (redirect) { router.push("/onboarding/download"); diff --git a/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx b/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx index 6b314f06333..d33117ac7c6 100644 --- a/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx +++ b/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx @@ -8,6 +8,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { startTransition, useRef, useState } from "react"; import { toast } from "sonner"; +import { trackEvent } from "@/app/utils/analytics"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { Base } from "./Base"; @@ -61,6 +62,10 @@ export function OrganizationSetupPage({ }); }), onSuccess: () => { + trackEvent("onboarding_milestone_completed", { + milestone: "organization_setup", + skipped: false, + }); startTransition(() => { router.push("/onboarding/custom-domain"); router.refresh(); diff --git a/apps/web/app/(org)/onboarding/components/WelcomePage.tsx b/apps/web/app/(org)/onboarding/components/WelcomePage.tsx index 5c3168c67b8..af685a8a224 100644 --- a/apps/web/app/(org)/onboarding/components/WelcomePage.tsx +++ b/apps/web/app/(org)/onboarding/components/WelcomePage.tsx @@ -4,6 +4,7 @@ import { Button, Input } from "@cap/ui"; import { useRouter } from "next/navigation"; import { startTransition, useState } from "react"; import { toast } from "sonner"; +import { trackEvent } from "@/app/utils/analytics"; import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { Base } from "./Base"; @@ -20,6 +21,10 @@ export function WelcomePage() { data, }), onSuccess: () => { + trackEvent("onboarding_milestone_completed", { + milestone: "welcome", + skipped: false, + }); startTransition(() => { router.push("/onboarding/organization-setup"); router.refresh(); diff --git a/apps/web/app/Layout/AppProviders.tsx b/apps/web/app/Layout/AppProviders.tsx index e38361175a5..375768bc264 100644 --- a/apps/web/app/Layout/AppProviders.tsx +++ b/apps/web/app/Layout/AppProviders.tsx @@ -4,21 +4,15 @@ 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 { 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 @@ -26,30 +20,28 @@ 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..5f2273792ef --- /dev/null +++ b/apps/web/app/Layout/ProductAnalyticsPageView.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { boundedForegroundEngagementMs } from "@cap/analytics"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; +import { + captureProductPageEngagement, + captureProductPageView, + flushBrowserProductAnalytics, + shouldCaptureProductPageView, + touchProductAnalyticsSession, +} from "../utils/product-analytics"; + +type CapturedPageView = { + location: string; + eventId: string; + sessionId: string; + sessionStartedAt: string; +}; + +let lastCapturedPageView: CapturedPageView | undefined; +const SESSION_TOUCH_THROTTLE_MS = 5_000; +const ENGAGEMENT_FLUSH_INTERVAL_MS = 5 * 60 * 1_000; + +export function ProductAnalyticsPageView() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const location = `${pathname}?${searchParams.toString()}`; + + useEffect(() => { + if (!pathname || !shouldCaptureProductPageView(pathname)) { + return; + } + const initialPageView = + lastCapturedPageView?.location === location + ? lastCapturedPageView + : captureProductPageView(); + if (!initialPageView) return; + let pageView = { ...initialPageView, location }; + lastCapturedPageView = pageView; + const initialActivityAt = performance.now(); + let activeSince = + document.visibilityState === "visible" ? initialActivityAt : undefined; + let lastInteractionAt = initialActivityAt; + let lastSessionTouchAt = initialActivityAt; + let pendingEngagedMs = 0; + let maxScrollDepth = 0; + const accrueEngagement = (now: number) => { + if (activeSince === undefined) return; + pendingEngagedMs += boundedForegroundEngagementMs({ + activeSince, + lastInteractionAt, + now, + }); + activeSince = undefined; + }; + const flushEngagement = (mode: "normal" | "unload") => { + accrueEngagement(performance.now()); + if (pendingEngagedMs > 0 || maxScrollDepth > 0) { + captureProductPageEngagement( + pageView.eventId, + pageView.sessionId, + pageView.sessionStartedAt, + pathname, + pendingEngagedMs, + maxScrollDepth, + ); + pendingEngagedMs = 0; + maxScrollDepth = 0; + } + if (mode === "unload") void flushBrowserProductAnalytics("unload"); + }; + const recordActivity = (forceSessionTouch = false) => { + if (document.visibilityState !== "visible") return; + const now = performance.now(); + accrueEngagement(now); + lastInteractionAt = now; + activeSince = now; + if ( + !forceSessionTouch && + now - lastSessionTouchAt < SESSION_TOUCH_THROTTLE_MS + ) { + return; + } + lastSessionTouchAt = now; + const context = touchProductAnalyticsSession(); + if (!context.isSessionEntry) return; + flushEngagement("normal"); + const nextPageView = captureProductPageView(context); + if (nextPageView) { + pageView = { ...nextPageView, location }; + lastCapturedPageView = pageView; + } + lastInteractionAt = now; + activeSince = now; + }; + const updateScrollDepth = () => { + const scrollable = + document.documentElement.scrollHeight - window.innerHeight; + const depth = scrollable <= 0 ? 100 : (window.scrollY / scrollable) * 100; + maxScrollDepth = Math.max(maxScrollDepth, Math.min(100, depth)); + recordActivity(); + }; + const handleActivity = () => recordActivity(); + const updateVisibility = () => { + if (document.visibilityState === "visible") { + recordActivity(true); + } else { + flushEngagement("unload"); + } + }; + const pageHide = () => flushEngagement("unload"); + window.addEventListener("scroll", updateScrollDepth, { passive: true }); + window.addEventListener("pointerdown", handleActivity, { passive: true }); + window.addEventListener("touchstart", handleActivity, { passive: true }); + window.addEventListener("keydown", handleActivity); + window.addEventListener("pagehide", pageHide, { passive: true }); + document.addEventListener("visibilitychange", updateVisibility); + const engagementInterval = window.setInterval( + () => flushEngagement("normal"), + ENGAGEMENT_FLUSH_INTERVAL_MS, + ); + updateScrollDepth(); + + return () => { + window.removeEventListener("scroll", updateScrollDepth); + window.removeEventListener("pointerdown", handleActivity); + window.removeEventListener("touchstart", handleActivity); + window.removeEventListener("keydown", handleActivity); + window.removeEventListener("pagehide", pageHide); + document.removeEventListener("visibilitychange", updateVisibility); + window.clearInterval(engagementInterval); + flushEngagement("normal"); + }; + }, [location, 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..b723946ccc4 --- /dev/null +++ b/apps/web/app/Layout/SignupAnalytics.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Suspense, useEffect } from "react"; +import { checkAndMarkUserSignedUpTracked } from "@/actions/analytics/track-user-signed-up"; +import { useCurrentUser } from "./AuthContext"; + +export function SignupAnalytics() { + return ( + + + + ); +} + +function Inner() { + const user = useCurrentUser(); + + useEffect(() => { + if (!user) return; + + void checkAndMarkUserSignedUpTracked(); + }, [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/admin/analytics/page.tsx b/apps/web/app/admin/analytics/page.tsx new file mode 100644 index 00000000000..340719c5320 --- /dev/null +++ b/apps/web/app/admin/analytics/page.tsx @@ -0,0 +1,1449 @@ +import { buildEnv } from "@cap/env"; +import { notFound } from "next/navigation"; +import { getProductAnalyticsOutboxHealth } from "@/lib/analytics/product-event-outbox"; +import { getViewerContext } from "@/lib/messenger/data"; +import { + AdminAnalyticsConfigurationError, + type AdminAnalyticsDashboard, + type AdminAnalyticsFilters, + AdminAnalyticsRequestError, + fetchAdminAnalyticsDashboard, +} from "./tinybird"; + +type SearchParams = Record; +type ProductAnalyticsOutboxHealth = Awaited< + ReturnType +>; + +type MetricCardProps = { + label: string; + value: string; + detail?: string; +}; + +const integerFormat = new Intl.NumberFormat("en-GB", { + maximumFractionDigits: 0, +}); + +const decimalFormat = new Intl.NumberFormat("en-GB", { + maximumFractionDigits: 2, +}); + +function MetricCard({ label, value, detail }: MetricCardProps) { + return ( +
+

+ {label} +

+

+ {value} +

+ {detail ?

{detail}

: null} +
+ ); +} + +function Section({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {children} +
+ ); +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function formatInteger(value: number): string { + return integerFormat.format(value); +} + +function formatDecimal(value: number): string { + return decimalFormat.format(value); +} + +function formatPercent(value: number): string { + return `${formatDecimal(value)}%`; +} + +function formatDuration(milliseconds: number): string { + if (!Number.isFinite(milliseconds) || milliseconds <= 0) return "0s"; + const seconds = Math.round(milliseconds / 1_000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +function formatTimestamp(value: string): string { + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) return "Unavailable"; + return timestamp.toLocaleString("en-GB", { + timeZone: "UTC", + dateStyle: "medium", + timeStyle: "short", + }); +} + +function sumBy(items: T[], readValue: (item: T) => number): number { + return items.reduce((sum, item) => sum + readValue(item), 0); +} + +function firstParam(params: SearchParams, key: string): string | undefined { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; +} + +function isDate(value: string | undefined): value is string { + if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return ( + !Number.isNaN(parsed.getTime()) && + parsed.toISOString().slice(0, 10) === value + ); +} + +function defaultDateRange(): Pick< + AdminAnalyticsFilters, + "startDate" | "endDate" +> { + const today = new Date(); + const endDate = today.toISOString().slice(0, 10); + today.setUTCDate(today.getUTCDate() - 29); + return { startDate: today.toISOString().slice(0, 10), endDate }; +} + +function optionalFilter(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized.slice(0, 100) : undefined; +} + +function parseFilters(params: SearchParams): AdminAnalyticsFilters { + const defaults = defaultDateRange(); + let startDate = firstParam(params, "start"); + let endDate = firstParam(params, "end"); + startDate = isDate(startDate) ? startDate : defaults.startDate; + endDate = isDate(endDate) ? endDate : defaults.endDate; + if (startDate > endDate) [startDate, endDate] = [endDate, startDate]; + + return { + startDate, + endDate, + platform: optionalFilter(firstParam(params, "platform")), + appVersion: optionalFilter(firstParam(params, "appVersion")), + acquisitionSource: optionalFilter(firstParam(params, "acquisitionSource")), + eventOrigin: optionalFilter(firstParam(params, "eventOrigin")), + country: optionalFilter(firstParam(params, "country"))?.toUpperCase(), + plan: optionalFilter(firstParam(params, "plan")), + organizationCohort: isDate(firstParam(params, "organizationCohort")) + ? firstParam(params, "organizationCohort") + : undefined, + }; +} + +function trafficTotals(data: AdminAnalyticsDashboard) { + const exact = data.trafficTotals[0]; + const visits = + exact?.visits ?? sumBy(data.trafficOverview, (row) => row.visits); + const pageviews = + exact?.pageviews ?? sumBy(data.trafficOverview, (row) => row.pageviews); + const weightedBounces = sumBy( + data.trafficOverview, + (row) => row.bounceRate * row.visits, + ); + const weightedDuration = sumBy( + data.trafficOverview, + (row) => row.visitDurationMs * row.visits, + ); + const latestDay = data.trafficOverview.at(-1); + return { + visitors: data.trafficTotalsAvailable ? (exact?.visitors ?? 0) : undefined, + visits, + pageviews, + viewsPerVisit: + exact?.viewsPerVisit ?? (visits === 0 ? 0 : pageviews / visits), + bounceRate: + exact?.bounceRate ?? (visits === 0 ? 0 : weightedBounces / visits), + visitDurationMs: + exact?.visitDurationMs ?? (visits === 0 ? 0 : weightedDuration / visits), + latestDay, + }; +} + +function activationTotals(data: AdminAnalyticsDashboard) { + const signups = sumBy(data.activation, (row) => row.signups); + const activatedCreators = sumBy( + data.activation, + (row) => row.activatedCreators, + ); + const weightedTime = sumBy( + data.activation, + (row) => row.averageTimeToActivationMs * row.activatedCreators, + ); + return { + signups, + activatedCreators, + activationRate: signups === 0 ? 0 : (100 * activatedCreators) / signups, + averageTimeToActivationMs: + activatedCreators === 0 ? 0 : weightedTime / activatedCreators, + }; +} + +function eventCount(data: AdminAnalyticsDashboard, eventName: string): number { + return sumBy( + data.productEvents.filter((row) => row.eventName === eventName), + (row) => row.events, + ); +} + +function recordingCount( + data: AdminAnalyticsDashboard, + status: "success" | "degraded" | "failed" | "unknown", +): number { + return sumBy( + data.productEvents.filter( + (row) => + row.eventName === "recording_completed" && + (row.recordingStatus || "unknown") === status, + ), + (row) => row.events, + ); +} + +function eventValue( + data: AdminAnalyticsDashboard, + eventName: string, + field: "attemptCount" | "seatDelta", +): number { + return sumBy( + data.productEvents.filter((row) => row.eventName === eventName), + (row) => row[field] * row.events, + ); +} + +function eventMaxValue( + data: AdminAnalyticsDashboard, + eventName: string, + field: "attemptCount", +): number { + return Math.max( + 0, + ...data.productEvents + .filter((row) => row.eventName === eventName) + .map((row) => row[field]), + ); +} + +function eventCountWhere( + data: AdminAnalyticsDashboard, + eventName: string, + predicate: (row: AdminAnalyticsDashboard["productEvents"][number]) => boolean, +): number { + return sumBy( + data.productEvents.filter( + (row) => row.eventName === eventName && predicate(row), + ), + (row) => row.events, + ); +} + +function retentionRate( + data: AdminAnalyticsDashboard, + cohortDay: number, + endDate: string, + metric: "creators" | "organizations" = "creators", +): number | undefined { + const end = new Date(`${endDate}T00:00:00.000Z`); + const eligibleCohorts = new Set( + data.creatorRetention + .filter((row) => { + const cohort = new Date(`${row.cohortDate}T00:00:00.000Z`); + return end.getTime() - cohort.getTime() >= cohortDay * 86_400_000; + }) + .map((row) => `${row.cohortDate}:${row.platform}`), + ); + const dayZero = sumBy( + data.creatorRetention.filter( + (row) => + row.cohortDay === 0 && + eligibleCohorts.has(`${row.cohortDate}:${row.platform}`), + ), + (row) => row[metric], + ); + if (dayZero === 0) return undefined; + const retained = sumBy( + data.creatorRetention.filter( + (row) => + row.cohortDay === cohortDay && + eligibleCohorts.has(`${row.cohortDate}:${row.platform}`), + ), + (row) => row[metric], + ); + return (100 * retained) / dayZero; +} + +function revenueTotals(data: AdminAnalyticsDashboard): Array<[string, number]> { + const totals = new Map(); + for (const row of data.productEvents) { + if (!row.currency || row.revenueMinor === 0) continue; + totals.set( + row.currency, + (totals.get(row.currency) ?? 0) + row.revenueMinor, + ); + } + return [...totals.entries()].sort(([left], [right]) => + left.localeCompare(right), + ); +} + +function featureAdoptionRows(data: AdminAnalyticsDashboard) { + return data.featureAdoption; +} + +function qualityStatus( + data: AdminAnalyticsDashboard, + outboxHealth: ProductAnalyticsOutboxHealth, +) { + const freshness = data.freshness[0]; + const health = data.health[0]; + if (!freshness || !health) { + return { + label: "Unavailable", + className: "bg-amber-100 text-amber-800", + }; + } + if ( + freshness.healthFreshnessMs > 7_200_000 || + health.payloadConflicts > 0 || + health.missingIdentityEvents > 0 || + !outboxHealth.healthy + ) { + return { + label: "Attention required", + className: "bg-red-100 text-red-800", + }; + } + return { label: "Healthy", className: "bg-emerald-100 text-emerald-800" }; +} + +function AnalyticsFilters({ filters }: { filters: AdminAnalyticsFilters }) { + const fieldClassName = + "mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-950 shadow-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-100"; + return ( +
+ + + + + + + + + +
+ + + Reset + +
+

+ Platform and app version affect product, retention, and health. + Acquisition source filters traffic and attribution; event origin filters + product facts. Plan and country affect endpoints that expose those + dimensions. Organization cohort selects one first-value UTC date for the + retention view. +

+
+ ); +} + +function AnalyticsError({ error }: { error: unknown }) { + const message = + error instanceof AdminAnalyticsConfigurationError || + error instanceof AdminAnalyticsRequestError + ? error.message + : "Admin analytics could not be loaded."; + return ( +
+

Analytics unavailable

+

{message}

+
+ ); +} + +function TrafficSection({ data }: { data: AdminAnalyticsDashboard }) { + const totals = trafficTotals(data); + return ( +
+
+ + + + + + + +
+

+ A current-online visitor count is intentionally not shown: the aggregate + API does not yet provide a reliable live-presence metric. +

+
+ ); +} + +function PagesSection({ data }: { data: AdminAnalyticsDashboard }) { + if (data.trafficPages.length === 0) { + return ( +
+ No page aggregates in this range. +
+ ); + } + return ( +
+
+ + + + + + + + + + + + + + {data.trafficPages.slice(0, 25).map((row) => ( + + + + + + + + + + ))} + +
PathVisitorsViewsLandingsExitsTimeScroll
+ {row.pathname} + + {formatInteger(row.visitors)} + + {formatInteger(row.pageviews)} + + {formatInteger(row.landings)} + + {formatInteger(row.exits)} + + {formatDuration(row.timeOnPageMs)} + + {formatPercent(row.averageScrollDepth)} +
+
+
+ ); +} + +function AcquisitionSection({ data }: { data: AdminAnalyticsDashboard }) { + return ( +
+ {data.trafficAttributionAvailable ? ( +
+

+ Campaign attribution models +

+ + + + + + + + + + + + + {data.trafficAttribution.slice(0, 45).map((row, index) => ( + + + + + + + + + ))} + +
ModelSourceCampaignVisitorsVisitsPageviews
+ {row.attributionModel} + + {row.source || "Direct"} + {row.medium ? ` · ${row.medium}` : ""} + + {row.campaign || "Unattributed"} + + {formatInteger(row.visitors)} + + {formatInteger(row.visits)} + + {formatInteger(row.pageviews)} +
+
+ ) : ( + + Campaign attribution models are unavailable on the active rollback + target. + + )} +
+
+

+ Sources and campaigns +

+ + + {data.trafficSources.slice(0, 15).map((row, index) => ( + + + + + ))} + +
+

+ {row.source || "Direct"} +

+

+ {[row.channel, row.medium, row.campaign] + .filter(Boolean) + .join(" · ") || "Unattributed"} +

+
+ {formatInteger(row.visits)} visits +
+
+
+

+ Countries +

+ + + {data.trafficCountries.slice(0, 15).map((row) => ( + + + + + ))} + +
+ {row.country || "Unknown"} + + {formatInteger(row.visits)} visits +
+
+
+

+ Technology +

+ + + {data.trafficTechnology.slice(0, 15).map((row, index) => ( + + + + + ))} + +
+

+ {row.device || "Unknown device"} +

+

+ {[row.browser, row.os].filter(Boolean).join(" · ") || + "Unknown"} +

+
+ {formatInteger(row.visits)} visits +
+
+
+
+ ); +} + +function ProductSection({ + data, + filters, +}: { + data: AdminAnalyticsDashboard; + filters: AdminAnalyticsFilters; +}) { + const activation = activationTotals(data); + const activity = data.creatorActivity[0]; + const d1 = retentionRate(data, 1, filters.endDate); + const d7 = retentionRate(data, 7, filters.endDate); + const d30 = retentionRate(data, 30, filters.endDate); + const organizationD1 = retentionRate( + data, + 1, + filters.endDate, + "organizations", + ); + const organizationD7 = retentionRate( + data, + 7, + filters.endDate, + "organizations", + ); + const organizationD30 = retentionRate( + data, + 30, + filters.endDate, + "organizations", + ); + const features = featureAdoptionRows(data); + return ( +
+
+ + + + + + + + + + + + + + + + + + +
+
+ + + + +
+
+ + + +
+
+
+

+ Feature adoption +

+

+ Adopter counts are daily uniques summed across the range, so they + are actor-days rather than period-unique people. +

+
+ + + + + + + + + + + + {features.slice(0, 40).map((row) => ( + + + + + + + + ))} + +
EventEventsActor-daysUser-days + Organization-days +
+ {row.eventName} + + {formatInteger(row.events)} + + {formatInteger(row.actorDays)} + + {formatInteger(row.userDays)} + + {formatInteger(row.organizationDays)} +
+
+
+ ); +} + +function RevenueSection({ data }: { data: AdminAnalyticsDashboard }) { + const revenue = revenueTotals(data); + return ( +
+
+ + + + row.firstPurchase === "true", + ), + )} + /> + + + row.changeKind === "plan" || row.changeKind === "seats", + ), + )} + /> + + row.changeKind === "cancellation_scheduled", + ), + )} + /> + + + row.fullyRefunded === "true", + ), + )} + /> + + + {revenue.map(([currency, minorUnits]) => ( + + ))} +
+
+ ); +} + +function ExperimentationSection({ data }: { data: AdminAnalyticsDashboard }) { + return ( +
+ {!data.experimentOutcomesAvailable ? ( + + Experiment outcomes are unavailable on the active rollback target. + + ) : data.experimentOutcomes.length === 0 ? ( + No experiment exposures in this period. + ) : ( +
+ + + + + + + + + + + + + + {data.experimentOutcomes.map((row) => ( + + + + + + + + + + ))} + +
ExperimentVersionVariantOutcomeExposedConvertedRate
+ {row.experimentId} + + {row.assignmentVersion} + {row.variant} + {row.outcomeName.replaceAll("_", " ")} + + {formatInteger(row.exposedActors)} + + {formatInteger(row.convertedActors)} + + {formatPercent(row.conversionRate)} +
+
+ )} +
+ ); +} + +function IdentityFunnelSection({ data }: { data: AdminAnalyticsDashboard }) { + if (!data.identityFunnelAvailable) { + return ( +
+ + Identity funnel metrics are unavailable on this deployment. + +
+ ); + } + if (data.identityFunnel.length === 0) { + return ( +
+ No identity funnel activity in this period. +
+ ); + } + const total = (field: keyof (typeof data.identityFunnel)[number]) => + data.identityFunnel.reduce((sum, row) => { + const value = row[field]; + return sum + (typeof value === "number" ? value : 0); + }, 0); + return ( +
+
+ + + + + + + + +
+
+ ); +} + +function QualitySection({ + data, + outboxHealth, +}: { + data: AdminAnalyticsDashboard; + outboxHealth: ProductAnalyticsOutboxHealth; +}) { + const freshness = data.freshness[0]; + const health = data.health[0]; + const status = qualityStatus(data, outboxHealth); + const lag = health?.ingestionLagMs ?? []; + const reportedDeliveryLosses = sumBy( + data.productEvents, + (row) => row.deliveryLossCount, + ); + const outboxPending = sumBy( + outboxHealth.partitions, + (row) => row.pending + row.workflowStarted, + ); + const outboxDeadLetters = sumBy( + outboxHealth.partitions, + (row) => row.deadLetter, + ); + const reconciliationFailures = sumBy( + outboxHealth.reconciliationFailures, + (row) => row.events, + ); + return ( +
+
+ + {status.label} + +

+ Fresh means health aggregates are no more than two hours old. +

+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} + +function Definitions() { + const definitions = [ + ["Metric timezone", "All cohorts, sessions, and reporting dates use UTC."], + [ + "Visitor", + "An anonymous, privacy-safe visitor key. The Visitors card merges exact aggregate states across the complete selected range and counts a visitor once.", + ], + [ + "Visit", + "A browser session separated by 30 minutes of inactivity across reloads, SPA navigation, and participating tabs.", + ], + [ + "Bounce", + "A visit with one pageview and no qualifying engagement before the session closes.", + ], + [ + "Activation", + "A signed-up creator whose first successful share link is created within seven UTC days.", + ], + [ + "Active creator", + "An authenticated creator with a creator-value event in the DAU, WAU, or MAU window.", + ], + [ + "Retention", + "Creator or organization activity on a cohort-relative day divided by its eligible day-zero cohort; immature cohorts are excluded.", + ], + [ + "Revenue", + "Server-authoritative tracked revenue in original-currency minor units. No FX or decimal conversion is applied here.", + ], + [ + "Identity stitching", + "A server-authoritative link or settled guest purchase connects one anonymous acquisition identity to an authenticated user. The endpoint returns cohort counts only, never the mapping.", + ], + [ + "Deduplication", + "Decision-facing endpoints are built from stable event IDs. Duplicate deliveries remain visible in health but count once in metrics.", + ], + [ + "Campaign attribution", + "First touch is the visitor's earliest stored campaign, session touch is fixed when a 30-minute session begins, and last touch is the latest campaign-bearing navigation. Each model is reported independently.", + ], + [ + "Experiment exposure", + "A typed, stable assignment rendered to an actor. The outcome cohorts include only recorded exposures, exclude conflicting variants, and measure the first authoritative signup, share creation, or paid purchase within 30 days.", + ], + [ + "Recording terminal status", + "Successful, degraded, failed, and unknown terminal recordings are counted separately. A failed terminal event never contributes to the successful recording metric.", + ], + [ + "Privacy", + "This page queries aggregate endpoints only and never renders actor, user, organization, network, or raw user-agent identifiers.", + ], + [ + "Filter coverage", + "Date applies throughout. Platform and app version apply to traffic, attribution, product, experiment, creator, retention, and health aggregates where those dimensions exist. Acquisition source filters traffic and attribution, while event origin filters client versus server product facts. Plan and country apply where those dimensions exist. Organization cohort selects a first-value UTC date for creator and organization retention.", + ], + ]; + return ( +
+
+ {definitions.map(([term, definition]) => ( +
+
{term}
+
+ {definition} +
+
+ ))} +
+
+ ); +} + +export default async function AdminAnalyticsPage({ + searchParams, +}: { + searchParams: Promise; +}) { + if (buildEnv.NEXT_PUBLIC_IS_CAP !== "true") notFound(); + + const viewer = await getViewerContext(); + if (!viewer.user || !viewer.isAdmin) notFound(); + + const filters = parseFilters(await searchParams); + let data: AdminAnalyticsDashboard; + let outboxHealth: ProductAnalyticsOutboxHealth; + try { + [data, outboxHealth] = await Promise.all([ + fetchAdminAnalyticsDashboard(filters), + getProductAnalyticsOutboxHealth(), + ]); + } catch (error) { + return ( +
+
+
+

+ Cap administration +

+

+ Analytics +

+
+ + +
+
+ ); + } + + return ( +
+
+
+
+

+ Cap administration +

+

+ First-party analytics +

+

+ Private, aggregate-only product and website decision metrics. No + raw personal identifiers are queried or displayed. +

+
+

+ {filters.startDate} – {filters.endDate} UTC +

+
+ + + + + + + + + + +
+
+ ); +} diff --git a/apps/web/app/admin/analytics/tinybird.ts b/apps/web/app/admin/analytics/tinybird.ts new file mode 100644 index 00000000000..30fff55d662 --- /dev/null +++ b/apps/web/app/admin/analytics/tinybird.ts @@ -0,0 +1,1004 @@ +import "server-only"; + +const ADMIN_ANALYTICS_ENDPOINTS = [ + "product_traffic_overview", + "product_traffic_totals", + "product_traffic_pages", + "product_traffic_sources", + "product_attribution", + "product_traffic_countries", + "product_traffic_technology", + "product_activation", + "product_creator_activity", + "product_creator_retention", + "product_identity_funnel", + "product_events_daily", + "product_feature_adoption", + "product_experiment_outcomes", + "product_events_health", + "product_analytics_freshness", +] as const; + +type AdminAnalyticsEndpoint = (typeof ADMIN_ANALYTICS_ENDPOINTS)[number]; +type QueryValue = string | number | undefined; +type QueryParams = Record; +type UnknownRecord = Record; + +export type AdminAnalyticsFilters = { + startDate: string; + endDate: string; + platform?: string; + appVersion?: string; + acquisitionSource?: string; + eventOrigin?: string; + country?: string; + plan?: string; + organizationCohort?: string; +}; + +export type TrafficOverviewRow = { + date: string; + visitors: number; + visits: number; + pageviews: number; + viewsPerVisit: number; + bounceRate: number; + visitDurationMs: number; + engagedMs: number; +}; + +export type TrafficTotalsRow = Omit; + +export type AttributionModel = "first" | "session" | "last"; + +export type TrafficAttributionRow = { + attributionModel: AttributionModel; + source: string; + medium: string; + campaign: string; + visitors: number; + visits: number; + pageviews: number; +}; + +export type TrafficPageRow = { + pathname: string; + visitors: number; + visits: number; + pageviews: number; + landings: number; + exits: number; + timeOnPageMs: number; + averageScrollDepth: number; +}; + +export type TrafficSourceRow = { + channel: string; + source: string; + medium: string; + campaign: string; + visitors: number; + visits: number; + pageviews: number; + bounceRate: number; +}; + +export type TrafficCountryRow = { + country: string; + visitors: number; + visits: number; + pageviews: number; +}; + +export type TrafficTechnologyRow = { + device: string; + browser: string; + os: string; + visitors: number; + visits: number; + pageviews: number; +}; + +export type ActivationRow = { + cohortDate: string; + signups: number; + activatedCreators: number; + activationRate: number; + averageTimeToActivationMs: number; +}; + +export type CreatorActivityRow = { + asOfDate: string; + dau: number; + wau: number; + mau: number; + dailyActiveOrganizations: number; + newCreators: number; + returningCreators: number; + dauWauStickiness: number; + dauMauStickiness: number; +}; + +export type CreatorRetentionRow = { + cohortDate: string; + activityDate: string; + cohortDay: number; + platform: string; + creators: number; + organizations: number; +}; + +export type ProductEventRow = { + date: string; + eventName: string; + schemaVersion: number; + source: string; + platform: string; + appVersion: string; + hostname: string; + country: string; + device: string; + browser: string; + os: string; + channel: string; + planId: string; + recordingStatus: string; + paymentStatus: string; + subscriptionStatus: string; + currency: string; + billingInterval: string; + changeKind: string; + previousStatus: string; + newStatus: string; + previousPlanId: string; + quantity: number; + previousQuantity: number; + newQuantity: number; + seatDelta: number; + firstPurchase: string; + guestCheckout: string; + onboarding: string; + cancelAtPeriodEnd: string; + fullyRefunded: string; + endedAt: number; + trialEndAt: number; + amountDueMinor: number; + attemptCount: number; + experimentId: string; + experimentVariant: string; + assignmentVersion: string; + deliveryLossCount: number; + events: number; + actors: number; + users: number; + organizations: number; + revenueMinor: number; +}; + +export type IdentityFunnelRow = { + linkedVisitors: number; + linkedUsers: number; + signupUsers: number; + organizations: number; + guestCheckoutVisitors: number; + guestPurchasers: number; + authenticatedCheckoutUsers: number; + webCheckoutUsers: number; + desktopCheckoutUsers: number; + mobileCheckoutUsers: number; + crossDeviceCheckoutUsers: number; + trialUsers: number; + purchasers: number; + signupRate: number; + purchaseRate: number; +}; + +export type FeatureAdoptionRow = { + eventName: string; + events: number; + actorDays: number; + userDays: number; + organizationDays: number; +}; + +export type ExperimentOutcomeRow = { + experimentId: string; + assignmentVersion: string; + variant: string; + platform: string; + appVersion: string; + outcomeName: string; + exposedActors: number; + convertedActors: number; + conversionRate: number; +}; + +export type ProductEventsHealthRow = { + receivedRows: number; + uniqueEvents: number; + uniquePayloads: number; + duplicateRows: number; + payloadConflicts: number; + futureEvents: number; + lateEvents: number; + missingIdentityEvents: number; + ingestionLagMs: number[]; +}; + +export type AnalyticsFreshnessRow = { + latestReceivedHour: string; + healthFreshnessMs: number; + productCalculatedAt: string; + trafficCalculatedAt: string; + retentionCalculatedAt: string; + identityCalculatedAt: string; + attributionCalculatedAt: string; + experimentCalculatedAt: string; +}; + +export type AdminAnalyticsDashboard = { + trafficOverview: TrafficOverviewRow[]; + trafficTotals: TrafficTotalsRow[]; + trafficTotalsAvailable: boolean; + trafficPages: TrafficPageRow[]; + trafficSources: TrafficSourceRow[]; + trafficAttribution: TrafficAttributionRow[]; + trafficAttributionAvailable: boolean; + trafficCountries: TrafficCountryRow[]; + trafficTechnology: TrafficTechnologyRow[]; + activation: ActivationRow[]; + creatorActivity: CreatorActivityRow[]; + creatorRetention: CreatorRetentionRow[]; + identityFunnel: IdentityFunnelRow[]; + identityFunnelAvailable: boolean; + productEvents: ProductEventRow[]; + featureAdoption: FeatureAdoptionRow[]; + experimentOutcomes: ExperimentOutcomeRow[]; + experimentOutcomesAvailable: boolean; + health: ProductEventsHealthRow[]; + freshness: AnalyticsFreshnessRow[]; + healthWindowStart: string; +}; + +export class AdminAnalyticsConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = "AdminAnalyticsConfigurationError"; + } +} + +export class AdminAnalyticsRequestError extends Error { + constructor( + message: string, + readonly status?: number, + ) { + super(message); + this.name = "AdminAnalyticsRequestError"; + } +} + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(row: UnknownRecord, key: string): string { + const value = row[key]; + if (typeof value !== "string") { + throw new AdminAnalyticsRequestError( + `Tinybird returned an invalid ${key} value`, + ); + } + return value; +} + +function readOptionalString(row: UnknownRecord, key: string): string { + const value = row[key]; + if (value === undefined || value === null) return ""; + return readString(row, key); +} + +function readNumber(row: UnknownRecord, key: string): number { + const value = row[key]; + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) { + throw new AdminAnalyticsRequestError( + `Tinybird returned an invalid ${key} value`, + ); + } + return parsed; +} + +function readOptionalNumber(row: UnknownRecord, key: string): number { + const value = row[key]; + if (value === undefined || value === null) return 0; + return readNumber(row, key); +} + +function readNumberArray(row: UnknownRecord, key: string): number[] { + const value = row[key]; + if (!Array.isArray(value)) { + throw new AdminAnalyticsRequestError( + `Tinybird returned an invalid ${key} value`, + ); + } + return value.map((item) => { + const parsed = typeof item === "number" ? item : Number(item); + if (!Number.isFinite(parsed)) { + throw new AdminAnalyticsRequestError( + `Tinybird returned an invalid ${key} value`, + ); + } + return parsed; + }); +} + +export function decodeTinybirdRows( + value: unknown, + decodeRow: (row: UnknownRecord) => T, +): T[] { + if (!isRecord(value) || !Array.isArray(value.data)) { + throw new AdminAnalyticsRequestError( + "Tinybird returned an invalid response envelope", + ); + } + + return value.data.map((row) => { + if (!isRecord(row)) { + throw new AdminAnalyticsRequestError( + "Tinybird returned an invalid response row", + ); + } + return decodeRow(row); + }); +} + +export function buildAdminAnalyticsEndpointUrl( + host: string, + endpoint: AdminAnalyticsEndpoint, + params: QueryParams, +): URL { + const normalizedHost = host.trim().replace(/\/+$/, ""); + let url: URL; + try { + url = new URL(`${normalizedHost}/v0/pipes/${endpoint}.json`); + } catch { + throw new AdminAnalyticsConfigurationError( + "PRODUCT_ANALYTICS_TINYBIRD_HOST must be a valid absolute URL", + ); + } + + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === "") continue; + url.searchParams.set(key, String(value)); + } + return url; +} + +function decodeTrafficOverviewRow(row: UnknownRecord): TrafficOverviewRow { + return { + date: readString(row, "date"), + visitors: readNumber(row, "visitors"), + visits: readNumber(row, "visits"), + pageviews: readNumber(row, "pageviews"), + viewsPerVisit: readNumber(row, "views_per_visit"), + bounceRate: readNumber(row, "bounce_rate"), + visitDurationMs: readNumber(row, "visit_duration_ms"), + engagedMs: readNumber(row, "engaged_ms"), + }; +} + +export function decodeTrafficOverviewResponse( + value: unknown, +): TrafficOverviewRow[] { + return decodeTinybirdRows(value, decodeTrafficOverviewRow); +} + +function decodeTrafficTotalsRow(row: UnknownRecord): TrafficTotalsRow { + return { + visitors: readNumber(row, "visitors"), + visits: readNumber(row, "visits"), + pageviews: readNumber(row, "pageviews"), + viewsPerVisit: readNumber(row, "views_per_visit"), + bounceRate: readNumber(row, "bounce_rate"), + visitDurationMs: readNumber(row, "visit_duration_ms"), + engagedMs: readNumber(row, "engaged_ms"), + }; +} + +export function decodeTrafficTotalsResponse( + value: unknown, +): TrafficTotalsRow[] { + return decodeTinybirdRows(value, decodeTrafficTotalsRow); +} + +function decodeAttributionModel( + row: UnknownRecord, + key: string, +): AttributionModel { + const value = readString(row, key); + if (value !== "first" && value !== "session" && value !== "last") { + throw new AdminAnalyticsRequestError( + `Tinybird returned an invalid ${key} value`, + ); + } + return value; +} + +function decodeTrafficAttributionRow( + row: UnknownRecord, +): TrafficAttributionRow { + return { + attributionModel: decodeAttributionModel(row, "attribution_model"), + source: readString(row, "source"), + medium: readString(row, "medium"), + campaign: readString(row, "campaign"), + visitors: readNumber(row, "visitors"), + visits: readNumber(row, "visits"), + pageviews: readNumber(row, "pageviews"), + }; +} + +export function decodeTrafficAttributionResponse( + value: unknown, +): TrafficAttributionRow[] { + return decodeTinybirdRows(value, decodeTrafficAttributionRow); +} + +function decodeTrafficPageRow(row: UnknownRecord): TrafficPageRow { + return { + pathname: readString(row, "pathname"), + visitors: readNumber(row, "visitors"), + visits: readNumber(row, "visits"), + pageviews: readNumber(row, "pageviews"), + landings: readNumber(row, "landings"), + exits: readNumber(row, "exits"), + timeOnPageMs: readNumber(row, "time_on_page_ms"), + averageScrollDepth: readNumber(row, "average_scroll_depth"), + }; +} + +function decodeTrafficSourceRow(row: UnknownRecord): TrafficSourceRow { + return { + channel: readString(row, "channel"), + source: readString(row, "source"), + medium: readString(row, "medium"), + campaign: readString(row, "campaign"), + visitors: readNumber(row, "visitors"), + visits: readNumber(row, "visits"), + pageviews: readNumber(row, "pageviews"), + bounceRate: readNumber(row, "bounce_rate"), + }; +} + +function decodeTrafficCountryRow(row: UnknownRecord): TrafficCountryRow { + return { + country: readString(row, "country"), + visitors: readNumber(row, "visitors"), + visits: readNumber(row, "visits"), + pageviews: readNumber(row, "pageviews"), + }; +} + +function decodeTrafficTechnologyRow(row: UnknownRecord): TrafficTechnologyRow { + return { + device: readString(row, "device"), + browser: readString(row, "browser"), + os: readString(row, "os"), + visitors: readNumber(row, "visitors"), + visits: readNumber(row, "visits"), + pageviews: readNumber(row, "pageviews"), + }; +} + +function decodeActivationRow(row: UnknownRecord): ActivationRow { + return { + cohortDate: readString(row, "cohort_date"), + signups: readNumber(row, "signups"), + activatedCreators: readNumber(row, "activated_creators"), + activationRate: readNumber(row, "activation_rate"), + averageTimeToActivationMs: readNumber(row, "average_time_to_activation_ms"), + }; +} + +function decodeCreatorActivityRow(row: UnknownRecord): CreatorActivityRow { + return { + asOfDate: readString(row, "as_of_date"), + dau: readNumber(row, "dau"), + wau: readNumber(row, "wau"), + mau: readNumber(row, "mau"), + dailyActiveOrganizations: readNumber(row, "daily_active_organizations"), + newCreators: readNumber(row, "new_creators"), + returningCreators: readNumber(row, "returning_creators"), + dauWauStickiness: readNumber(row, "dau_wau_stickiness"), + dauMauStickiness: readNumber(row, "dau_mau_stickiness"), + }; +} + +function decodeCreatorRetentionRow(row: UnknownRecord): CreatorRetentionRow { + return { + cohortDate: readString(row, "cohort_date"), + activityDate: readString(row, "activity_date"), + cohortDay: readNumber(row, "cohort_day"), + platform: readString(row, "platform"), + creators: readNumber(row, "creators"), + organizations: readNumber(row, "organizations"), + }; +} + +function decodeIdentityFunnelRow(row: UnknownRecord): IdentityFunnelRow { + return { + linkedVisitors: readNumber(row, "linked_visitors"), + linkedUsers: readNumber(row, "linked_users"), + signupUsers: readNumber(row, "signup_users"), + organizations: readNumber(row, "organizations"), + guestCheckoutVisitors: readNumber(row, "guest_checkout_visitors"), + guestPurchasers: readNumber(row, "guest_purchasers"), + authenticatedCheckoutUsers: readNumber(row, "authenticated_checkout_users"), + webCheckoutUsers: readNumber(row, "web_checkout_users"), + desktopCheckoutUsers: readNumber(row, "desktop_checkout_users"), + mobileCheckoutUsers: readNumber(row, "mobile_checkout_users"), + crossDeviceCheckoutUsers: readNumber(row, "cross_device_checkout_users"), + trialUsers: readNumber(row, "trial_users"), + purchasers: readNumber(row, "purchasers"), + signupRate: readNumber(row, "signup_rate"), + purchaseRate: readNumber(row, "purchase_rate"), + }; +} + +function decodeProductEventRow(row: UnknownRecord): ProductEventRow { + return { + date: readString(row, "date"), + eventName: readString(row, "event_name"), + schemaVersion: readOptionalNumber(row, "schema_version"), + source: readString(row, "source"), + platform: readString(row, "platform"), + appVersion: readString(row, "app_version"), + hostname: readString(row, "hostname"), + country: readString(row, "country"), + device: readString(row, "device"), + browser: readString(row, "browser"), + os: readString(row, "os"), + channel: readString(row, "channel"), + planId: readString(row, "plan_id"), + recordingStatus: readOptionalString(row, "recording_status"), + paymentStatus: readString(row, "payment_status"), + subscriptionStatus: readString(row, "subscription_status"), + currency: readString(row, "currency"), + billingInterval: readString(row, "billing_interval"), + changeKind: readOptionalString(row, "change_kind"), + previousStatus: readOptionalString(row, "previous_status"), + newStatus: readOptionalString(row, "new_status"), + previousPlanId: readOptionalString(row, "previous_plan_id"), + quantity: readOptionalNumber(row, "quantity"), + previousQuantity: readOptionalNumber(row, "previous_quantity"), + newQuantity: readOptionalNumber(row, "new_quantity"), + seatDelta: readOptionalNumber(row, "seat_delta"), + firstPurchase: readOptionalString(row, "first_purchase"), + guestCheckout: readOptionalString(row, "guest_checkout"), + onboarding: readOptionalString(row, "onboarding"), + cancelAtPeriodEnd: readOptionalString(row, "cancel_at_period_end"), + fullyRefunded: readOptionalString(row, "fully_refunded"), + endedAt: readOptionalNumber(row, "ended_at"), + trialEndAt: readOptionalNumber(row, "trial_end_at"), + amountDueMinor: readOptionalNumber(row, "amount_due_minor"), + attemptCount: readOptionalNumber(row, "attempt_count"), + experimentId: readOptionalString(row, "experiment_id"), + experimentVariant: readOptionalString(row, "experiment_variant"), + assignmentVersion: readOptionalString(row, "assignment_version"), + deliveryLossCount: readOptionalNumber(row, "delivery_loss_count"), + events: readNumber(row, "events"), + actors: readNumber(row, "actors"), + users: readNumber(row, "users"), + organizations: readNumber(row, "organizations"), + revenueMinor: readNumber(row, "revenue_minor"), + }; +} + +function decodeFeatureAdoptionRow(row: UnknownRecord): FeatureAdoptionRow { + return { + eventName: readString(row, "event_name"), + events: readNumber(row, "events"), + actorDays: readNumber(row, "actor_days"), + userDays: readNumber(row, "user_days"), + organizationDays: readNumber(row, "organization_days"), + }; +} + +function decodeExperimentOutcomeRow(row: UnknownRecord): ExperimentOutcomeRow { + return { + experimentId: readString(row, "experiment_id"), + assignmentVersion: readString(row, "assignment_version"), + variant: readString(row, "variant"), + platform: readString(row, "platform"), + appVersion: readString(row, "app_version"), + outcomeName: readString(row, "outcome_name"), + exposedActors: readNumber(row, "exposed_actors"), + convertedActors: readNumber(row, "converted_actors"), + conversionRate: readNumber(row, "conversion_rate"), + }; +} + +export function decodeExperimentOutcomesResponse( + value: unknown, +): ExperimentOutcomeRow[] { + return decodeTinybirdRows(value, decodeExperimentOutcomeRow); +} + +function decodeProductEventsHealthRow( + row: UnknownRecord, +): ProductEventsHealthRow { + return { + receivedRows: readNumber(row, "received_rows"), + uniqueEvents: readNumber(row, "unique_events"), + uniquePayloads: readNumber(row, "unique_payloads"), + duplicateRows: readNumber(row, "duplicate_rows"), + payloadConflicts: readNumber(row, "payload_conflicts"), + futureEvents: readNumber(row, "future_events"), + lateEvents: readNumber(row, "late_events"), + missingIdentityEvents: readNumber(row, "missing_identity_events"), + ingestionLagMs: readNumberArray(row, "ingestion_lag_ms"), + }; +} + +function decodeAnalyticsFreshnessRow( + row: UnknownRecord, +): AnalyticsFreshnessRow { + return { + latestReceivedHour: readString(row, "latest_received_hour"), + healthFreshnessMs: readNumber(row, "health_freshness_ms"), + productCalculatedAt: readString(row, "product_calculated_at"), + trafficCalculatedAt: readString(row, "traffic_calculated_at"), + retentionCalculatedAt: readString(row, "retention_calculated_at"), + identityCalculatedAt: readOptionalString(row, "identity_calculated_at"), + attributionCalculatedAt: readOptionalString( + row, + "attribution_calculated_at", + ), + experimentCalculatedAt: readOptionalString(row, "experiment_calculated_at"), + }; +} + +export function decodeProductEventsResponse(value: unknown): ProductEventRow[] { + return decodeTinybirdRows(value, decodeProductEventRow); +} + +export function decodeAnalyticsFreshnessResponse( + value: unknown, +): AnalyticsFreshnessRow[] { + return decodeTinybirdRows(value, decodeAnalyticsFreshnessRow); +} + +type FetchEndpoint = ( + endpoint: AdminAnalyticsEndpoint, + params: QueryParams, + decodeRow: (row: UnknownRecord) => T, +) => Promise; + +function createFetchEndpoint(): FetchEndpoint { + const host = process.env.PRODUCT_ANALYTICS_TINYBIRD_HOST?.trim(); + const token = process.env.PRODUCT_ANALYTICS_TINYBIRD_READ_TOKEN?.trim(); + if (!host || !token) { + throw new AdminAnalyticsConfigurationError( + "Admin analytics is not configured. Set PRODUCT_ANALYTICS_TINYBIRD_HOST and PRODUCT_ANALYTICS_TINYBIRD_READ_TOKEN.", + ); + } + + return async ( + endpoint: AdminAnalyticsEndpoint, + params: QueryParams, + decodeRow: (row: UnknownRecord) => T, + ) => { + const url = buildAdminAnalyticsEndpointUrl(host, endpoint, params); + let response: Response; + try { + response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new AdminAnalyticsRequestError( + `Tinybird endpoint ${endpoint} could not be reached`, + ); + } + + if (!response.ok) { + throw new AdminAnalyticsRequestError( + `Tinybird endpoint ${endpoint} returned HTTP ${response.status}`, + response.status, + ); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new AdminAnalyticsRequestError( + `Tinybird endpoint ${endpoint} returned invalid JSON`, + ); + } + return decodeTinybirdRows(body, decodeRow); + }; +} + +export async function fetchOptionalRollbackEndpoint( + fetchEndpoint: FetchEndpoint, + endpoint: AdminAnalyticsEndpoint, + params: QueryParams, + decodeRow: (row: UnknownRecord) => T, +): Promise<{ available: boolean; rows: T[] }> { + try { + return { + available: true, + rows: await fetchEndpoint(endpoint, params, decodeRow), + }; + } catch (error) { + if (error instanceof AdminAnalyticsRequestError && error.status === 404) { + return { available: false, rows: [] }; + } + throw error; + } +} + +export function calculateHealthWindowStart( + startDate: string, + endDate: string, +): string { + const requestedStart = new Date(`${startDate}T00:00:00.000Z`); + const end = new Date(`${endDate}T23:59:59.999Z`); + const maximumStart = new Date(end); + maximumStart.setUTCDate(maximumStart.getUTCDate() - 30); + return new Date(Math.max(requestedStart.getTime(), maximumStart.getTime())) + .toISOString() + .slice(0, 10); +} + +export function assertAdminAnalyticsDateRange( + startDate: string, + endDate: string, +) { + const rangeStart = new Date(`${startDate}T00:00:00.000Z`); + const rangeEnd = new Date(`${endDate}T00:00:00.000Z`); + if ( + !Number.isFinite(rangeStart.getTime()) || + !Number.isFinite(rangeEnd.getTime()) || + rangeEnd < rangeStart || + rangeEnd.getTime() - rangeStart.getTime() > 799 * 86_400_000 + ) { + throw new AdminAnalyticsRequestError( + "The analytics date range must be valid, ordered, and no longer than 800 UTC days.", + ); + } +} + +export async function fetchAdminAnalyticsDashboard( + filters: AdminAnalyticsFilters, +): Promise { + assertAdminAnalyticsDateRange(filters.startDate, filters.endDate); + + const fetchEndpoint = createFetchEndpoint(); + const dateParams = { + start_date: filters.startDate, + end_date: filters.endDate, + }; + const healthWindowStart = calculateHealthWindowStart( + filters.startDate, + filters.endDate, + ); + + const [ + trafficOverview, + trafficTotalsResult, + trafficPages, + trafficSources, + trafficAttributionResult, + trafficCountries, + trafficTechnology, + activation, + creatorActivity, + creatorRetention, + identityFunnelResult, + productEvents, + featureAdoption, + experimentOutcomesResult, + health, + freshness, + ] = await Promise.all([ + fetchEndpoint( + "product_traffic_overview", + { + ...dateParams, + platform: filters.platform, + app_version: filters.appVersion, + country: filters.country, + }, + decodeTrafficOverviewRow, + ), + fetchOptionalRollbackEndpoint( + fetchEndpoint, + "product_traffic_totals", + { + ...dateParams, + platform: filters.platform, + app_version: filters.appVersion, + source: filters.acquisitionSource, + country: filters.country, + }, + decodeTrafficTotalsRow, + ), + fetchEndpoint( + "product_traffic_pages", + { + ...dateParams, + platform: filters.platform, + app_version: filters.appVersion, + country: filters.country, + limit: 100, + }, + decodeTrafficPageRow, + ), + fetchEndpoint( + "product_traffic_sources", + { + ...dateParams, + platform: filters.platform, + app_version: filters.appVersion, + limit: 100, + }, + decodeTrafficSourceRow, + ), + fetchOptionalRollbackEndpoint( + fetchEndpoint, + "product_attribution", + { + ...dateParams, + platform: filters.platform, + app_version: filters.appVersion, + source: filters.acquisitionSource, + country: filters.country, + limit: 300, + }, + decodeTrafficAttributionRow, + ), + fetchEndpoint( + "product_traffic_countries", + { + ...dateParams, + platform: filters.platform, + app_version: filters.appVersion, + }, + decodeTrafficCountryRow, + ), + fetchEndpoint( + "product_traffic_technology", + { + ...dateParams, + platform: filters.platform, + app_version: filters.appVersion, + country: filters.country, + }, + decodeTrafficTechnologyRow, + ), + fetchEndpoint("product_activation", dateParams, decodeActivationRow), + fetchEndpoint( + "product_creator_activity", + { as_of_date: filters.endDate, platform: filters.platform }, + decodeCreatorActivityRow, + ), + fetchEndpoint( + "product_creator_retention", + { + start_date: filters.organizationCohort ?? filters.startDate, + end_date: filters.organizationCohort ?? filters.endDate, + platform: filters.platform, + }, + decodeCreatorRetentionRow, + ), + fetchOptionalRollbackEndpoint( + fetchEndpoint, + "product_identity_funnel", + { + ...dateParams, + source: filters.acquisitionSource, + country: filters.country, + }, + decodeIdentityFunnelRow, + ), + fetchEndpoint( + "product_events_daily", + { + ...dateParams, + platform: filters.platform, + app_version: filters.appVersion, + source: filters.eventOrigin, + country: filters.country, + plan_id: filters.plan, + limit: 1000, + }, + decodeProductEventRow, + ), + fetchEndpoint( + "product_feature_adoption", + { + ...dateParams, + platform: filters.platform, + app_version: filters.appVersion, + source: filters.eventOrigin, + country: filters.country, + plan_id: filters.plan, + }, + decodeFeatureAdoptionRow, + ), + fetchOptionalRollbackEndpoint( + fetchEndpoint, + "product_experiment_outcomes", + { + ...dateParams, + platform: filters.platform, + app_version: filters.appVersion, + limit: 1000, + }, + decodeExperimentOutcomeRow, + ), + fetchEndpoint( + "product_events_health", + { + start_time: `${healthWindowStart} 00:00:00.000`, + end_time: `${filters.endDate} 23:59:59.999`, + platform: filters.platform, + app_version: filters.appVersion, + }, + decodeProductEventsHealthRow, + ), + fetchEndpoint( + "product_analytics_freshness", + {}, + decodeAnalyticsFreshnessRow, + ), + ]); + + if (productEvents.length >= 1000) { + throw new AdminAnalyticsRequestError( + "Product analytics reached the aggregate endpoint row limit. Narrow the date range or filters before using these metrics for decisions.", + ); + } + if (trafficAttributionResult.rows.length >= 300) { + throw new AdminAnalyticsRequestError( + "Campaign attribution reached the aggregate endpoint row limit. Narrow the date range or filters before using these metrics for decisions.", + ); + } + if (experimentOutcomesResult.rows.length >= 1000) { + throw new AdminAnalyticsRequestError( + "Experiment outcomes reached the aggregate endpoint row limit. Narrow the date range or filters before using these metrics for decisions.", + ); + } + if (creatorRetention.length >= 5000) { + throw new AdminAnalyticsRequestError( + "Creator retention reached the aggregate endpoint row limit. Narrow the date range or platform before using these metrics for decisions.", + ); + } + + return { + trafficOverview, + trafficTotals: trafficTotalsResult.rows, + trafficTotalsAvailable: trafficTotalsResult.available, + trafficPages, + trafficSources, + trafficAttribution: trafficAttributionResult.rows, + trafficAttributionAvailable: trafficAttributionResult.available, + trafficCountries, + trafficTechnology, + activation, + creatorActivity, + creatorRetention, + identityFunnel: identityFunnelResult.rows, + identityFunnelAvailable: identityFunnelResult.available, + productEvents, + featureAdoption, + experimentOutcomes: experimentOutcomesResult.rows, + experimentOutcomesAvailable: experimentOutcomesResult.available, + health, + freshness, + healthWindowStart, + }; +} diff --git a/apps/web/app/api/analytics/staging-test/[[...route]]/route.ts b/apps/web/app/api/analytics/staging-test/[[...route]]/route.ts new file mode 100644 index 00000000000..8fc22bd442b --- /dev/null +++ b/apps/web/app/api/analytics/staging-test/[[...route]]/route.ts @@ -0,0 +1,852 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import { + productAnalyticsEventIdHash, + productAnalyticsIdentityHash, +} from "@cap/analytics"; +import { db } from "@cap/database"; +import { + productAnalyticsErasureRequests, + productAnalyticsEventReceipts, + productAnalyticsIdentityLinks, + productAnalyticsIdentityState, + productAnalyticsOutbox, +} from "@cap/database/schema"; +import { Tinybird } from "@cap/web-backend"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, + HttpServerRequest, +} from "@effect/platform"; +import { inArray, or, sql } from "drizzle-orm"; +import { Effect, Layer, Schema } from "effect"; +import type Stripe from "stripe"; +import { queueServerProductEvent } from "@/lib/analytics/server"; +import type { ServerProductEvent } from "@/lib/analytics/server-event"; +import { subscriptionCheckoutProductEvent } from "@/lib/analytics/stripe-business-events"; +import { apiToHandler } from "@/lib/server"; + +class Api extends HttpApi.make("AnalyticsStagingTestApi").add( + HttpApiGroup.make("stagingTest") + .add( + HttpApiEndpoint.post("run", "/api/analytics/staging-test") + .setPayload( + Schema.Struct({ + scenario: Schema.Literal("business_lifecycle"), + runId: Schema.String, + sha: Schema.String, + }), + ) + .addSuccess( + Schema.Struct({ + accepted: Schema.Number, + uniqueEvents: Schema.Number, + workflowRuns: Schema.Array(Schema.String), + }), + ) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Unauthorized) + .addError(HttpApiError.NotFound) + .addError(HttpApiError.ServiceUnavailable), + ) + .add( + HttpApiEndpoint.post("erase", "/api/analytics/staging-test/erase") + .setPayload( + Schema.Struct({ + runId: Schema.String, + sha: Schema.String, + }), + ) + .addSuccess(Schema.Struct({ erased: Schema.Boolean })) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Unauthorized) + .addError(HttpApiError.NotFound) + .addError(HttpApiError.ServiceUnavailable), + ) + .add( + HttpApiEndpoint.post("health", "/api/analytics/staging-test/health") + .setPayload( + Schema.Struct({ + runId: Schema.String, + sha: Schema.String, + }), + ) + .addSuccess( + Schema.Struct({ + activeEvents: Schema.Number, + deadLetterEvents: Schema.Number, + healthy: Schema.Boolean, + oldestActiveAgeSeconds: Schema.Number, + payloadConflictEvents: Schema.Number, + provider429Retries: Schema.Number, + provider503Retries: Schema.Number, + providerRejectedDeadLetters: Schema.Number, + receiptPayloadConflictAttempts: Schema.Number, + receiptPayloadConflictEvents: Schema.Number, + timeoutAfterAcceptRetries: Schema.Number, + }), + ) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Unauthorized) + .addError(HttpApiError.NotFound) + .addError(HttpApiError.ServiceUnavailable), + ) + .add( + HttpApiEndpoint.post( + "cleanupDatabase", + "/api/analytics/staging-test/cleanup-database", + ) + .setPayload( + Schema.Struct({ + anonymousIdentityHashes: Schema.Array(Schema.String), + runId: Schema.String, + scopeRunIds: Schema.Array(Schema.String), + sha: Schema.String, + }), + ) + .addSuccess( + Schema.Struct({ + cleaned: Schema.Boolean, + identityHashes: Schema.Number, + remaining: Schema.Number, + runIds: Schema.Number, + }), + ) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Unauthorized) + .addError(HttpApiError.NotFound) + .addError(HttpApiError.ServiceUnavailable), + ) + .add( + HttpApiEndpoint.post("attest", "/api/analytics/staging-test/attest") + .setPayload( + Schema.Struct({ + runId: Schema.String, + sha: Schema.String, + }), + ) + .addSuccess( + Schema.Struct({ + databaseFingerprint: Schema.String, + databaseSchema: Schema.Literal("0042_lying_sharon_ventura"), + host: Schema.String, + sha: Schema.String, + workspaces: Schema.Array( + Schema.Struct({ + name: Schema.String, + tokenHash: Schema.String, + workspaceId: Schema.String, + }), + ), + }), + ) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Unauthorized) + .addError(HttpApiError.NotFound) + .addError(HttpApiError.ServiceUnavailable), + ), +) {} + +const RequestHeaders = Schema.Struct({ + authorization: Schema.optional(Schema.String), + "x-cap-analytics-staging-signature": Schema.optional(Schema.String), +}); + +const safeEqual = (actual: string | undefined, expected: string) => + Boolean( + actual && + actual.length === expected.length && + timingSafeEqual(Buffer.from(actual), Buffer.from(expected)), + ); + +const boundedRunId = (value: string) => + /^[A-Za-z0-9_-]{8,128}$/.test(value) ? value : undefined; + +const draftSha = (value: string) => /^[0-9a-f]{40}$/.test(value); + +const syntheticIdentities = (runId: string) => { + const hash = createHash("sha256").update(runId).digest("hex"); + return { + anonymousId: `synthetic_${hash.slice(0, 24)}`, + hash, + organizationId: `synthetic_org_${hash.slice(24, 48)}`, + userId: `synthetic_user_${hash.slice(0, 24)}`, + }; +}; + +const syntheticEventIds = (runId: string) => { + const { hash } = syntheticIdentities(runId); + return [ + `staging_signup_${hash.slice(0, 24)}`, + `staging_retry_429_${hash.slice(0, 24)}`, + `staging_retry_503_${hash.slice(0, 24)}`, + `staging_ambiguous_${hash.slice(0, 24)}`, + `staging_reject_400_${hash.slice(0, 24)}`, + `staging_erasure_replay_${hash.slice(0, 24)}`, + ]; +}; + +const syntheticIdentityHashes = (runId: string) => { + const { anonymousId, organizationId, userId } = syntheticIdentities(runId); + return [ + productAnalyticsIdentityHash("anonymous", anonymousId), + productAnalyticsIdentityHash("organization", organizationId), + productAnalyticsIdentityHash("user", userId), + ]; +}; + +const scopedDatabaseHealth = async (runId: string) => { + const eventIds = syntheticEventIds(runId); + const eventIdHashes = eventIds.map(productAnalyticsEventIdHash); + const [outboxRows, receiptRows] = await Promise.all([ + db() + .select({ + createdAt: productAnalyticsOutbox.createdAt, + lastErrorCode: productAnalyticsOutbox.lastErrorCode, + payloadConflict: productAnalyticsOutbox.payloadConflict, + status: productAnalyticsOutbox.status, + }) + .from(productAnalyticsOutbox) + .where(inArray(productAnalyticsOutbox.eventId, eventIds)), + db() + .select({ conflictCount: productAnalyticsEventReceipts.conflictCount }) + .from(productAnalyticsEventReceipts) + .where(inArray(productAnalyticsEventReceipts.eventIdHash, eventIdHashes)), + ]); + const activeRows = outboxRows.filter((row) => + ["pending", "workflow_started"].includes(row.status), + ); + const oldestActiveAt = activeRows.reduce( + (oldest, row) => Math.min(oldest, row.createdAt.getTime()), + Date.now(), + ); + const countError = (code: string) => + outboxRows.filter((row) => row.lastErrorCode === code).length; + const receiptPayloadConflictEvents = receiptRows.filter( + (row) => row.conflictCount > 0, + ).length; + const receiptPayloadConflictAttempts = receiptRows.reduce( + (sum, row) => sum + row.conflictCount, + 0, + ); + const deadLetterEvents = outboxRows.filter( + (row) => row.status === "dead_letter", + ).length; + const payloadConflictEvents = outboxRows.filter( + (row) => row.payloadConflict, + ).length; + return { + activeEvents: activeRows.length, + deadLetterEvents, + healthy: + activeRows.length === 0 && + deadLetterEvents === 0 && + payloadConflictEvents === 0 && + receiptPayloadConflictEvents === 0, + oldestActiveAgeSeconds: + activeRows.length === 0 + ? 0 + : Math.max(0, Math.floor((Date.now() - oldestActiveAt) / 1_000)), + payloadConflictEvents, + provider429Retries: countError("staging_provider_429"), + provider503Retries: countError("staging_provider_503"), + providerRejectedDeadLetters: outboxRows.filter( + (row) => + row.status === "dead_letter" && + row.lastErrorCode === "provider_rejected", + ).length, + receiptPayloadConflictAttempts, + receiptPayloadConflictEvents, + timeoutAfterAcceptRetries: countError("staging_timeout_after_accept"), + }; +}; + +const cleanupSyntheticDatabaseState = async ({ + anonymousIdentityHashes, + runIds, +}: { + anonymousIdentityHashes: readonly string[]; + runIds: readonly string[]; +}) => { + const identities = runIds.map(syntheticIdentities); + const eventIds = runIds.flatMap(syntheticEventIds); + const eventIdHashes = eventIds.map(productAnalyticsEventIdHash); + const identityHashes = [ + ...new Set([ + ...anonymousIdentityHashes, + ...runIds.flatMap(syntheticIdentityHashes), + ]), + ]; + const anonymousIds = identities.map(({ anonymousId }) => anonymousId); + const organizationIds = identities.map( + ({ organizationId }) => organizationId, + ); + const userIds = identities.map(({ userId }) => userId); + await db().transaction(async (tx) => { + await tx + .delete(productAnalyticsIdentityLinks) + .where( + or( + inArray( + productAnalyticsIdentityLinks.anonymousIdentityHash, + identityHashes, + ), + inArray( + productAnalyticsIdentityLinks.userIdentityHash, + identityHashes, + ), + inArray( + productAnalyticsIdentityLinks.organizationIdentityHash, + identityHashes, + ), + ), + ); + await tx + .delete(productAnalyticsEventReceipts) + .where( + or( + inArray(productAnalyticsEventReceipts.eventIdHash, eventIdHashes), + inArray( + productAnalyticsEventReceipts.anonymousIdentityHash, + identityHashes, + ), + inArray( + productAnalyticsEventReceipts.userIdentityHash, + identityHashes, + ), + inArray( + productAnalyticsEventReceipts.organizationIdentityHash, + identityHashes, + ), + ), + ); + await tx + .delete(productAnalyticsOutbox) + .where( + or( + inArray(productAnalyticsOutbox.eventId, eventIds), + inArray(productAnalyticsOutbox.anonymousId, anonymousIds), + inArray(productAnalyticsOutbox.userId, userIds), + inArray(productAnalyticsOutbox.organizationId, organizationIds), + ), + ); + await tx + .delete(productAnalyticsErasureRequests) + .where( + or( + inArray(productAnalyticsErasureRequests.userId, userIds), + inArray( + productAnalyticsErasureRequests.organizationId, + organizationIds, + ), + ), + ); + await tx + .delete(productAnalyticsIdentityState) + .where( + inArray(productAnalyticsIdentityState.identityHash, identityHashes), + ); + }); + const [remainingOutbox, remainingReceipts, remainingLinks, remainingStates] = + await Promise.all([ + db() + .select({ eventId: productAnalyticsOutbox.eventId }) + .from(productAnalyticsOutbox) + .where(inArray(productAnalyticsOutbox.eventId, eventIds)), + db() + .select({ eventIdHash: productAnalyticsEventReceipts.eventIdHash }) + .from(productAnalyticsEventReceipts) + .where( + inArray(productAnalyticsEventReceipts.eventIdHash, eventIdHashes), + ), + db() + .select({ + anonymousIdentityHash: + productAnalyticsIdentityLinks.anonymousIdentityHash, + }) + .from(productAnalyticsIdentityLinks) + .where( + or( + inArray( + productAnalyticsIdentityLinks.anonymousIdentityHash, + identityHashes, + ), + inArray( + productAnalyticsIdentityLinks.userIdentityHash, + identityHashes, + ), + ), + ), + db() + .select({ identityHash: productAnalyticsIdentityState.identityHash }) + .from(productAnalyticsIdentityState) + .where( + inArray(productAnalyticsIdentityState.identityHash, identityHashes), + ), + ]); + return { + identityHashes: identityHashes.length, + remaining: + remainingOutbox.length + + remainingReceipts.length + + remainingLinks.length + + remainingStates.length, + runIds: runIds.length, + }; +}; + +const tinybirdTokenNames = [ + "PRODUCT_ANALYTICS_TINYBIRD_TOKEN", + "PRODUCT_ANALYTICS_TINYBIRD_READ_TOKEN", + "PRODUCT_ANALYTICS_TINYBIRD_ERASURE_TOKEN", + "PRODUCT_ANALYTICS_TINYBIRD_ERASURE_LOOKUP_TOKEN", + "PRODUCT_ANALYTICS_TINYBIRD_COPY_TOKEN", + "PRODUCT_ANALYTICS_TINYBIRD_SCHEDULER_TOKEN", +] as const; +const TINYBIRD_STAGING_ORIGIN = "https://api.us-east.aws.tinybird.co"; +const TINYBIRD_STAGING_WORKSPACE_ID = "37b8fef9-817f-4c3c-b21f-218c36a6077d"; +const STAGING_DATABASE_FINGERPRINT = + "fff37a9b160f31bfb82b8c5585829b8ee08f70b3645169dca6e7cb29033a039a"; + +const tokenWorkspaceId = (token: string) => { + const segment = token.split(".")[1]; + if (!segment) return undefined; + try { + const payload: unknown = JSON.parse( + Buffer.from(segment, "base64url").toString("utf8"), + ); + if (!payload || typeof payload !== "object") return undefined; + const record = payload as Record; + const workspaceId = record.u ?? record.workspace_id ?? record.workspaceId; + return typeof workspaceId === "string" ? workspaceId : undefined; + } catch { + return undefined; + } +}; + +const configurationAttestation = (runId: string) => { + const host = process.env.PRODUCT_ANALYTICS_TINYBIRD_HOST; + const sha = process.env.VERCEL_GIT_COMMIT_SHA; + const secret = process.env.CAP_ANALYTICS_STAGING_TEST_SECRET; + const databaseUrl = process.env.DATABASE_URL; + if (!host || !sha || !secret || !databaseUrl) return undefined; + const databaseFingerprint = createHash("sha256") + .update(databaseUrl) + .digest("hex"); + if (databaseFingerprint !== STAGING_DATABASE_FINGERPRINT) return undefined; + let origin: string; + try { + origin = new URL(host).origin; + } catch { + return undefined; + } + const workspaces = tinybirdTokenNames.map((name) => { + const token = process.env[name]; + if (!token) return undefined; + const workspaceId = tokenWorkspaceId(token); + if (!workspaceId) return undefined; + return { + name, + tokenHash: createHmac("sha256", `${secret}:${runId}`) + .update(token) + .digest("hex"), + workspaceId, + }; + }); + if (workspaces.some((workspace) => !workspace)) return undefined; + if ( + origin !== TINYBIRD_STAGING_ORIGIN || + workspaces.some( + (workspace) => workspace?.workspaceId !== TINYBIRD_STAGING_WORKSPACE_ID, + ) + ) { + return undefined; + } + return { + databaseFingerprint, + host: origin, + sha, + workspaces: workspaces.filter( + (workspace): workspace is NonNullable => + Boolean(workspace), + ), + }; +}; + +const databaseResultRows = (result: unknown) => { + if (!Array.isArray(result)) return []; + const rows = Array.isArray(result[0]) ? result[0] : result; + return rows.filter( + (row): row is Record => + typeof row === "object" && row !== null && !Array.isArray(row), + ); +}; + +const attestDatabaseSchema = async () => { + const tableNames = [ + "comments", + "organization_invites", + "product_analytics_erasure_requests", + "product_analytics_event_receipts", + "product_analytics_identity_links", + "product_analytics_ingestion_leases", + "product_analytics_outbox", + "product_analytics_refresh_leases", + "product_analytics_reconciliation_failures", + "users", + "videos", + ]; + const tableNamesSql = sql.join( + tableNames.map((tableName) => sql`${tableName}`), + sql`, `, + ); + const [columnResult, indexResult] = await Promise.all([ + db().execute(sql` + SELECT TABLE_NAME AS tableName, COLUMN_NAME AS columnName, + COLUMN_TYPE AS columnType, IS_NULLABLE AS isNullable, + COLUMN_DEFAULT AS columnDefault, COLUMN_KEY AS columnKey + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN (${tableNamesSql}) + `), + db().execute(sql` + SELECT TABLE_NAME AS tableName, INDEX_NAME AS indexName, + NON_UNIQUE AS nonUnique, SEQ_IN_INDEX AS sequence, + COLUMN_NAME AS columnName + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN (${tableNamesSql}) + `), + ]); + const columnSignatures = new Set( + databaseResultRows(columnResult).map((row) => + [ + row.tableName, + row.columnName, + row.columnType, + row.isNullable, + row.columnDefault ?? "NULL", + row.columnKey ?? "", + ] + .map(String) + .join(":"), + ), + ); + const indexSignatures = new Set( + databaseResultRows(indexResult).map((row) => + [ + row.tableName, + row.indexName, + Number(row.nonUnique), + Number(row.sequence), + row.columnName, + ].join(":"), + ), + ); + const requiredColumns = [ + "organization_invites:emailDeliveryState:varchar(32):NO:legacy:MUL", + "product_analytics_event_receipts:eventIdHash:varchar(64):NO:NULL:PRI", + "product_analytics_ingestion_leases:fencingToken:bigint unsigned:NO:NULL:", + "product_analytics_outbox:eventId:varchar(128):NO:NULL:PRI", + "product_analytics_refresh_leases:name:varchar(64):NO:NULL:PRI", + "product_analytics_reconciliation_failures:attemptCount:int:NO:1:", + "product_analytics_reconciliation_failures:sourceHash:varchar(64):NO:NULL:PRI", + ]; + const requiredIndexes = [ + "comments:analytics_reconciliation_idx:1:1:createdAt", + "comments:analytics_reconciliation_idx:1:2:id", + "organization_invites:email_delivery_idx:1:1:emailDeliveryState", + "organization_invites:email_delivery_idx:1:2:emailDeliveryNextAttemptAt", + "organization_invites:normalized_email_idx:0:1:organizationId", + "organization_invites:normalized_email_idx:0:2:invitedEmailNormalized", + "product_analytics_erasure_requests:scope_hash_idx:0:1:scopeHash", + "product_analytics_event_receipts:PRIMARY:0:1:eventIdHash", + "product_analytics_event_receipts:conflict_idx:1:1:conflictCount", + "product_analytics_identity_links:PRIMARY:0:1:anonymousIdentityHash", + "product_analytics_identity_links:PRIMARY:0:2:userIdentityHash", + "product_analytics_ingestion_leases:PRIMARY:0:1:id", + "product_analytics_ingestion_leases:expiry_idx:1:1:expiresAt", + "product_analytics_outbox:PRIMARY:0:1:eventId", + "product_analytics_outbox:delivery_key_idx:0:1:deliveryKey", + "product_analytics_outbox:delivery_idx:1:1:status", + "product_analytics_outbox:delivery_idx:1:2:nextAttemptAt", + "product_analytics_outbox:delivery_idx:1:3:createdAt", + "product_analytics_refresh_leases:PRIMARY:0:1:name", + "product_analytics_refresh_leases:expiry_idx:1:1:leaseExpiresAt", + "product_analytics_refresh_leases:status_idx:1:1:status", + "product_analytics_reconciliation_failures:PRIMARY:0:1:sourceHash", + "product_analytics_reconciliation_failures:source_type_idx:1:1:sourceType", + "product_analytics_reconciliation_failures:source_type_idx:1:2:lastSeenAt", + "users:analytics_reconciliation_idx:1:1:created_at", + "users:analytics_reconciliation_idx:1:2:id", + "videos:analytics_created_at_idx:1:1:createdAt", + "videos:analytics_created_at_idx:1:2:id", + "videos:analytics_first_view_at_idx:1:1:firstExternalViewAt", + "videos:analytics_first_view_at_idx:1:2:id", + ]; + if ( + requiredColumns.some((signature) => !columnSignatures.has(signature)) || + requiredIndexes.some((signature) => !indexSignatures.has(signature)) + ) { + throw new Error("The staging database schema signature is incomplete"); + } +}; + +const authorize = (payload: { runId: string; sha: string }) => + Effect.gen(function* () { + const secret = process.env.CAP_ANALYTICS_STAGING_TEST_SECRET; + if (!secret) { + return yield* Effect.fail(new HttpApiError.ServiceUnavailable()); + } + const headers = yield* HttpServerRequest.schemaHeaders(RequestHeaders).pipe( + Effect.mapError(() => new HttpApiError.BadRequest()), + ); + if (!safeEqual(headers.authorization, `Bearer ${secret}`)) { + return yield* Effect.fail(new HttpApiError.Unauthorized()); + } + const runId = boundedRunId(payload.runId); + if (!runId || !draftSha(payload.sha)) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + const expectedSignature = createHmac("sha256", secret) + .update(`${runId}:${payload.sha}`) + .digest("hex"); + if ( + !safeEqual( + headers["x-cap-analytics-staging-signature"], + expectedSignature, + ) + ) { + return yield* Effect.fail(new HttpApiError.Unauthorized()); + } + if ( + payload.sha !== process.env.VERCEL_GIT_COMMIT_SHA || + !configurationAttestation(runId) + ) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + return runId; + }); + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "stagingTest", (handlers) => + Effect.gen(function* () { + const tinybird = yield* Tinybird; + return handlers + .handle("attest", ({ payload }) => + Effect.gen(function* () { + const runId = yield* authorize(payload); + const attestation = configurationAttestation(runId); + if (!attestation) { + return yield* Effect.fail( + new HttpApiError.ServiceUnavailable(), + ); + } + yield* Effect.tryPromise({ + try: attestDatabaseSchema, + catch: () => new HttpApiError.ServiceUnavailable(), + }); + return { + ...attestation, + databaseSchema: "0042_lying_sharon_ventura" as const, + }; + }), + ) + .handle("run", ({ payload }) => + Effect.gen(function* () { + const runId = yield* authorize(payload); + const { anonymousId, hash, organizationId, userId } = + syntheticIdentities(runId); + const occurredAt = new Date().toISOString(); + const purchase = subscriptionCheckoutProductEvent({ + eventId: `staging_ambiguous_${hash.slice(0, 24)}`, + occurredAt, + session: { + amount_subtotal: 2_500, + amount_total: 2_500, + currency: "usd", + metadata: { + analyticsAnonymousId: anonymousId, + analyticsIsFirstPurchase: "true", + analyticsOrganizationId: organizationId, + analyticsPriceId: "price_staging_annual", + analyticsQuantity: "1", + analyticsSchemaVersion: "1", + isOnBoarding: "false", + platform: "web", + }, + payment_status: "paid", + total_details: { amount_discount: 0 }, + } as unknown as Stripe.Checkout.Session, + user: { id: userId }, + }); + if (!purchase) { + return yield* Effect.fail( + new HttpApiError.ServiceUnavailable(), + ); + } + const events: ServerProductEvent[] = [ + { + _syntheticRunId: runId, + anonymousId, + eventId: `staging_signup_${hash.slice(0, 24)}`, + eventName: "user_signed_up", + occurredAt, + organizationId, + platform: "web", + userId, + }, + { + _syntheticRunId: runId, + anonymousId, + eventId: `staging_retry_429_${hash.slice(0, 24)}`, + eventName: "share_link_created", + occurredAt, + organizationId, + platform: "server", + properties: { + asset_type: "recording", + recording_mode: "screen", + }, + userId, + }, + { + _syntheticRunId: runId, + anonymousId, + eventId: `staging_retry_503_${hash.slice(0, 24)}`, + eventName: "checkout_started", + occurredAt, + organizationId, + platform: "web", + properties: { + is_onboarding: false, + price_id: "price_staging_annual", + quantity: 1, + }, + userId, + }, + { ...purchase, _syntheticRunId: runId }, + { + _syntheticRunId: runId, + anonymousId, + eventId: `staging_reject_400_${hash.slice(0, 24)}`, + eventName: "organization_member_joined", + occurredAt, + organizationId, + platform: "web", + properties: { + assigned_pro_seat: false, + role: "member", + }, + userId, + }, + ]; + const deliveries = yield* Effect.tryPromise({ + try: () => Promise.all(events.map(queueServerProductEvent)), + catch: () => new HttpApiError.ServiceUnavailable(), + }); + if ( + deliveries.some((delivery) => delivery.status !== "started") + ) { + return yield* Effect.fail( + new HttpApiError.ServiceUnavailable(), + ); + } + return { + accepted: events.length, + uniqueEvents: new Set(events.map((event) => event.eventId)) + .size, + workflowRuns: deliveries.map(({ runId }) => runId), + }; + }), + ) + .handle("health", ({ payload }) => + Effect.gen(function* () { + const runId = yield* authorize(payload); + const health = yield* Effect.tryPromise({ + try: () => scopedDatabaseHealth(runId), + catch: () => new HttpApiError.ServiceUnavailable(), + }); + return health; + }), + ) + .handle("cleanupDatabase", ({ payload }) => + Effect.gen(function* () { + yield* authorize(payload); + const runIds = [ + ...new Set(payload.scopeRunIds.map(boundedRunId)), + ].filter((runId) => runId !== undefined); + const anonymousIdentityHashes = [ + ...new Set(payload.anonymousIdentityHashes), + ].filter((identityHash) => /^[0-9a-f]{64}$/.test(identityHash)); + if ( + runIds.length === 0 || + runIds.length !== payload.scopeRunIds.length || + runIds.length > 8 || + anonymousIdentityHashes.length !== + payload.anonymousIdentityHashes.length || + anonymousIdentityHashes.length > 16 + ) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + const cleanup = yield* Effect.tryPromise({ + try: () => + cleanupSyntheticDatabaseState({ + anonymousIdentityHashes, + runIds, + }), + catch: () => new HttpApiError.ServiceUnavailable(), + }); + if (cleanup.remaining !== 0) { + return yield* Effect.fail( + new HttpApiError.ServiceUnavailable(), + ); + } + return { ...cleanup, cleaned: true }; + }), + ) + .handle("erase", ({ payload }) => + Effect.gen(function* () { + const runId = yield* authorize(payload); + const { anonymousId, hash, organizationId, userId } = + syntheticIdentities(runId); + yield* tinybird + .eraseProductAnalytics({ + userId, + organizationId, + }) + .pipe( + Effect.mapError(() => new HttpApiError.ServiceUnavailable()), + ); + const replay = yield* Effect.tryPromise({ + try: () => + queueServerProductEvent({ + _syntheticRunId: runId, + anonymousId, + eventId: `staging_erasure_replay_${hash.slice(0, 24)}`, + eventName: "user_signed_up", + occurredAt: new Date().toISOString(), + organizationId, + platform: "web", + userId, + }), + catch: () => new HttpApiError.ServiceUnavailable(), + }); + if (replay.status !== "suppressed") { + return yield* Effect.fail( + new HttpApiError.ServiceUnavailable(), + ); + } + return { erased: true }; + }), + ); + }), + ), + ), +); + +const postHandler = apiToHandler(ApiLive); +export const POST = postHandler; diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 9386d1d249a..ca18dd24517 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -1,17 +1,30 @@ import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; import { provideOptionalAuth, Tinybird } from "@cap/web-backend"; import { CurrentUser, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import type { NextRequest } from "next/server"; import UAParser from "ua-parser-js"; - +import { firstViewReceivedEvent } from "@/lib/analytics/business-events"; +import { + claimFirstExternalView, + firstExternalViewTimestamp, +} from "@/lib/analytics/first-view"; +import { + classifyAnalyticsTraffic, + getProductAnalyticsRateLimitKey, + normalizeSyntheticRunId, + ProductAnalyticsRateLimiter, +} from "@/lib/analytics/request"; +import { queueServerProductEvent } from "@/lib/analytics/server"; import { getAnonymousName } from "@/lib/anonymous-names"; import { createAnonymousViewNotification, sendFirstViewEmail, } from "@/lib/Notification"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { runPromise } from "@/lib/server"; interface TrackPayload { @@ -21,11 +34,13 @@ interface TrackPayload { sessionId?: string; pathname?: string; hostname?: string | null; - userAgent?: string; occurredAt?: string; } const VIEW_TRACKING_DELAY_MS = 2 * 60 * 1000; +const fallbackRateLimiter = new ProductAnalyticsRateLimiter({ + perKeyLimit: 60, +}); const sanitizeString = (value?: string | null) => { const trimmed = value?.trim(); @@ -59,10 +74,63 @@ export async function POST(request: NextRequest) { : null; const sessionId = parsedSessionId && parsedSessionId !== "anonymous" ? parsedSessionId : null; - const userAgent = - sanitizeString(request.headers.get("user-agent")) || - sanitizeString(body.userAgent) || - "unknown"; + const origin = request.headers.get("origin"); + if (origin) { + try { + if (new URL(origin).hostname !== request.nextUrl.hostname) { + return Response.json({ error: "Invalid origin" }, { status: 403 }); + } + } catch { + return Response.json({ error: "Invalid origin" }, { status: 403 }); + } + } + const isVercel = process.env.VERCEL === "1"; + const trustedNetworkProxy = + isVercel || process.env.CAP_ANALYTICS_TRUST_PROXY === "1"; + if (!trustedNetworkProxy) { + return Response.json( + { error: "Analytics proxy identity is not configured" }, + { status: 503 }, + ); + } + const rateLimitKey = getProductAnalyticsRateLimitKey({ + trustedNetworkProxy, + forwardedFor: + request.headers.get( + isVercel ? "x-vercel-forwarded-for" : "x-forwarded-for", + ) ?? undefined, + }); + if (!rateLimitKey) { + return Response.json( + { error: "Missing request identity" }, + { status: 400 }, + ); + } + if ( + fallbackRateLimiter.isRateLimited(rateLimitKey) || + (await isRateLimited(RATE_LIMIT_IDS.ANALYTICS_TRACK, { + key: rateLimitKey, + headers: request.headers, + })) + ) { + return Response.json({ error: "Rate limited" }, { status: 429 }); + } + const userAgent = sanitizeString(request.headers.get("user-agent")) || ""; + const environment = serverEnv(); + const syntheticRunId = normalizeSyntheticRunId( + request.headers.get("x-cap-analytics-test-run") ?? undefined, + environment.VERCEL_ENV, + ); + const trafficClass = classifyAnalyticsTraffic({ + userAgent, + vercelEnvironment: environment.VERCEL_ENV, + syntheticRunId, + rateLimitKey, + internalIpHashes: process.env.PRODUCT_ANALYTICS_INTERNAL_IP_HASHES, + }); + if (trafficClass !== "external") { + return Response.json({ success: true, excluded: trafficClass }); + } const parser = new UAParser(userAgent); const browserName = parser.getBrowser().name ?? "unknown"; const osName = parser.getOS().name ?? "unknown"; @@ -79,11 +147,6 @@ export async function POST(request: NextRequest) { decodeUrlEncodedHeaderValue(request.headers.get("x-vercel-ip-city")), ) || ""; - const hostname = - sanitizeString(body.hostname) || - sanitizeString(request.nextUrl.hostname) || - ""; - const pathname = body.pathname ?? `/s/${body.videoId}`; await runPromise( @@ -108,6 +171,7 @@ export async function POST(request: NextRequest) { db() .select({ ownerId: videos.ownerId, + organizationId: videos.orgId, firstViewEmailSentAt: videos.firstViewEmailSentAt, videoName: videos.name, createdAt: videos.createdAt, @@ -123,6 +187,7 @@ export async function POST(request: NextRequest) { () => [] as { ownerId: string; + organizationId: string; firstViewEmailSentAt: Date | null; videoName: string; createdAt: Date; @@ -132,7 +197,7 @@ export async function POST(request: NextRequest) { ), ); - if (videoRecord && userId === videoRecord.ownerId) { + if (!videoRecord || userId === videoRecord.ownerId) { return; } @@ -144,11 +209,26 @@ export async function POST(request: NextRequest) { return; } - const tenantId = - body.orgId || - videoRecord?.ownerId || - body.ownerId || - (hostname ? `domain:${hostname}` : "public"); + const tenantId = videoRecord.organizationId; + const isNewVideo = videoRecord.createdAt >= ANON_NOTIF_CUTOFF; + if (isNewVideo) { + const claimedAt = firstExternalViewTimestamp(); + const claimed = yield* Effect.tryPromise(() => + claimFirstExternalView(Video.VideoId.make(body.videoId), claimedAt), + ); + if (claimed) { + yield* Effect.tryPromise(() => + queueServerProductEvent( + firstViewReceivedEvent({ + videoId: body.videoId, + userId: videoRecord.ownerId, + organizationId: videoRecord.organizationId, + createdAt: claimedAt, + }), + ), + ); + } + } const tinybird = yield* Tinybird; yield* tinybird.appendEvents([ @@ -170,8 +250,6 @@ export async function POST(request: NextRequest) { }, ]); - const isNewVideo = - videoRecord && videoRecord.createdAt >= ANON_NOTIF_CUTOFF; const shouldSendFirstViewEmail = isNewVideo && !videoRecord.firstViewEmailSentAt; diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts index e89058ce413..e74823298c5 100644 --- a/apps/web/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -1,8 +1,14 @@ import { authOptions } from "@cap/database/auth/auth-options"; import NextAuth from "next-auth"; +import { recordWebAuthenticationSuccess } from "@/lib/analytics/authentication-events"; export const dynamic = "force-dynamic"; -const handler = NextAuth(authOptions()); +const options = authOptions(); +options.events = { + ...options.events, + signIn: ({ user }) => recordWebAuthenticationSuccess(user.id), +}; +const handler = NextAuth(options); export { handler as GET, handler as POST }; diff --git a/apps/web/app/api/cron/drain-product-analytics-outbox/route.ts b/apps/web/app/api/cron/drain-product-analytics-outbox/route.ts new file mode 100644 index 00000000000..965fd4fa629 --- /dev/null +++ b/apps/web/app/api/cron/drain-product-analytics-outbox/route.ts @@ -0,0 +1,36 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextResponse } from "next/server"; +import { start } from "workflow/api"; +import { getProductAnalyticsOutboxHealth } from "@/lib/analytics/product-event-outbox"; +import { drainProductAnalyticsOutboxWorkflow } from "@/workflows/drain-product-analytics-outbox"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + return NextResponse.json( + { error: "Server misconfiguration" }, + { status: 500 }, + ); + } + + const authorization = request.headers.get("authorization"); + const expected = `Bearer ${cronSecret}`; + if ( + !authorization || + authorization.length !== expected.length || + !timingSafeEqual(Buffer.from(authorization), Buffer.from(expected)) + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [run, health] = await Promise.all([ + start(drainProductAnalyticsOutboxWorkflow, []), + getProductAnalyticsOutboxHealth(), + ]); + return NextResponse.json( + { accepted: true, runId: run.runId, health }, + { status: health.healthy ? 200 : 503 }, + ); +} diff --git a/apps/web/app/api/cron/reconcile-product-analytics/route.ts b/apps/web/app/api/cron/reconcile-product-analytics/route.ts new file mode 100644 index 00000000000..067de50d1e4 --- /dev/null +++ b/apps/web/app/api/cron/reconcile-product-analytics/route.ts @@ -0,0 +1,31 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextResponse } from "next/server"; +import { start } from "workflow/api"; +import { reconcileProductAnalyticsWorkflow } from "@/workflows/reconcile-product-analytics"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + return NextResponse.json( + { error: "Server misconfiguration" }, + { status: 500 }, + ); + } + + const authorization = request.headers.get("authorization"); + const expected = `Bearer ${cronSecret}`; + if ( + !authorization || + authorization.length !== expected.length || + !timingSafeEqual(Buffer.from(authorization), Buffer.from(expected)) + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const run = await start(reconcileProductAnalyticsWorkflow, [ + { scheduledAt: new Date().toISOString(), lookbackHours: 24 * 7 }, + ]); + return NextResponse.json({ accepted: true, runId: run.runId }); +} diff --git a/apps/web/app/api/cron/recover-organization-invite-delivery/route.ts b/apps/web/app/api/cron/recover-organization-invite-delivery/route.ts new file mode 100644 index 00000000000..6b61cebccc2 --- /dev/null +++ b/apps/web/app/api/cron/recover-organization-invite-delivery/route.ts @@ -0,0 +1,33 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextResponse } from "next/server"; +import { start } from "workflow/api"; +import { getOrganizationInviteDeliveryHealth } from "@/lib/organization-invite-delivery"; +import { recoverOrganizationInviteDeliveriesWorkflow } from "@/workflows/recover-organization-invite-delivery"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + return NextResponse.json( + { error: "Server misconfiguration" }, + { status: 500 }, + ); + } + + const authorization = request.headers.get("authorization"); + const expected = `Bearer ${cronSecret}`; + if ( + !authorization || + authorization.length !== expected.length || + !timingSafeEqual(Buffer.from(authorization), Buffer.from(expected)) + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [run, health] = await Promise.all([ + start(recoverOrganizationInviteDeliveriesWorkflow, []), + getOrganizationInviteDeliveryHealth(), + ]); + return NextResponse.json({ accepted: true, runId: run.runId, health }); +} diff --git a/apps/web/app/api/cron/recover-product-analytics-erasure/route.ts b/apps/web/app/api/cron/recover-product-analytics-erasure/route.ts new file mode 100644 index 00000000000..5b077a9b562 --- /dev/null +++ b/apps/web/app/api/cron/recover-product-analytics-erasure/route.ts @@ -0,0 +1,35 @@ +import { timingSafeEqual } from "node:crypto"; +import { Tinybird } from "@cap/web-backend"; +import { Effect } from "effect"; +import { NextResponse } from "next/server"; +import { runPromise } from "@/lib/server"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + return NextResponse.json( + { error: "Server misconfiguration" }, + { status: 500 }, + ); + } + + const authorization = request.headers.get("authorization"); + const expected = `Bearer ${cronSecret}`; + if ( + !authorization || + authorization.length !== expected.length || + !timingSafeEqual(Buffer.from(authorization), Buffer.from(expected)) + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const result = await runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + return yield* tinybird.recoverProductAnalyticsErasure; + }), + ); + return NextResponse.json({ success: true, ...result }); +} diff --git a/apps/web/app/api/cron/refresh-product-analytics/route.ts b/apps/web/app/api/cron/refresh-product-analytics/route.ts new file mode 100644 index 00000000000..90475f3d6f9 --- /dev/null +++ b/apps/web/app/api/cron/refresh-product-analytics/route.ts @@ -0,0 +1,29 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextResponse } from "next/server"; +import { start } from "workflow/api"; +import { refreshProductAnalyticsWorkflow } from "@/workflows/refresh-product-analytics"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + return NextResponse.json( + { error: "Server misconfiguration" }, + { status: 500 }, + ); + } + const authorization = request.headers.get("authorization"); + const expected = `Bearer ${cronSecret}`; + if ( + !authorization || + authorization.length !== expected.length || + !timingSafeEqual(Buffer.from(authorization), Buffer.from(expected)) + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const run = await start(refreshProductAnalyticsWorkflow, [ + { scheduledAt: new Date().toISOString() }, + ]); + return NextResponse.json({ accepted: true, runId: run.runId }); +} diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 1971c197572..e7810db3dc6 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,11 @@ 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 { checkoutStartedEvent } from "@/lib/analytics/business-events"; +import { queueServerProductEvent } from "@/lib/analytics/server"; +import { subscriptionCheckoutAnalyticsMetadata } from "@/lib/analytics/stripe-business-events"; import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout"; import { runPromise } from "@/lib/server"; import { withAuth, withOptionalAuth } from "../../utils"; @@ -707,7 +709,7 @@ app.post( async (c) => { const { priceId, platform } = c.req.valid("json"); const user = c.get("user"); - const checkoutPlatform = platform ?? "desktop"; + const checkoutPlatform = platform === "mobile" ? "mobile" : "desktop"; if (userIsPro(user)) { console.log("[POST] Error: User already on Pro plan"); @@ -775,6 +777,13 @@ app.post( checkoutPlatform, serverEnv().WEB_URL, ); + const analyticsMetadata = subscriptionCheckoutAnalyticsMetadata({ + platform: checkoutPlatform, + priceId, + quantity: 1, + organizationId: user.activeOrganizationId, + isFirstPurchase: !user.stripeSubscriptionId, + }); const checkoutSession = await stripe().checkout.sessions.create({ customer: customerId as string, line_items: [{ price: priceId, quantity: 1 }], @@ -782,31 +791,30 @@ app.post( success_url: redirects.successUrl, cancel_url: redirects.cancelUrl, allow_promotion_codes: true, - metadata: { platform: checkoutPlatform, dubCustomerId: user.id }, + metadata: { + dubCustomerId: user.id, + ...analyticsMetadata, + }, + subscription_data: { metadata: analyticsMetadata }, }); 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); - } + await queueServerProductEvent( + checkoutStartedEvent({ + checkoutId: checkoutSession.id, + createdAt: new Date(checkoutSession.created * 1_000), + platform: checkoutPlatform, + userId: user.id, + organizationId: user.activeOrganizationId, + priceId, + quantity: 1, + }), + ).catch(() => { + console.warn( + "Desktop checkout analytics enqueue failed; reconciliation pending", + ); + }); return c.json({ url: checkoutSession.url }); } diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 4d22e637bd7..0ca851ac0b7 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -20,6 +20,8 @@ import { and, count, eq, lte } from "drizzle-orm"; import { Effect, Option } from "effect"; import { Hono } from "hono"; import { z } from "zod"; +import { shareLinkCreatedEvent } from "@/lib/analytics/business-events"; +import { queueServerProductEvent } from "@/lib/analytics/server"; import { invalidateGoogleDriveStorageQuotaCache } from "@/lib/google-drive-storage-quota"; import { runPromise } from "@/lib/server"; import { decodeStorageVideo } from "@/lib/video-storage"; @@ -61,6 +63,9 @@ app.get( zValidator( "query", z.object({ + initiatingPlatform: z + .union([z.literal("cli"), z.literal("desktop")]) + .default("desktop"), recordingMode: z .union([ z.literal("hls"), @@ -86,6 +91,7 @@ app.get( async (c) => { try { const { + initiatingPlatform, recordingMode, isScreenshot, videoId, @@ -215,13 +221,23 @@ app.get( } const idToUse = Video.VideoId.make(nanoId()); + const createdAt = new Date(); + const videoSource = + recordingMode === "hls" + ? ({ type: "local" } as const) + : recordingMode === "desktopMP4" + ? ({ type: "desktopMP4" } as const) + : recordingMode === "desktopSegments" + ? ({ type: "desktopSegments" } as const) + : ({ type: "MediaConvert" } as const); const videoName = name ?? `Cap ${isScreenshot ? "Screenshot" : "Recording"} - ${formattedDate}`; - const metadata: VideoMetadata | undefined = name - ? { sourceName: name } - : undefined; + const metadata: VideoMetadata = { + initiatingPlatform, + ...(name ? { sourceName: name } : {}), + }; const clientSupportsGoogleDriveUpload = hasDesktopFeature( c.req, GOOGLE_DRIVE_UPLOAD_FEATURE, @@ -253,15 +269,9 @@ app.get( name: videoName, ownerId: user.id, orgId: videoOrgId, - source: - recordingMode === "hls" - ? { type: "local" as const } - : recordingMode === "desktopMP4" - ? { type: "desktopMP4" as const } - : recordingMode === "desktopSegments" - ? { type: "desktopSegments" as const } - : undefined, + source: videoSource, isScreenshot, + createdAt, bucket: Option.getOrNull(writable.bucketId), storageIntegrationId: Option.getOrNull(writable.storageIntegrationId), public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, @@ -269,9 +279,27 @@ app.get( width, height, fps, - ...(metadata ? { metadata } : {}), + metadata, }); + const analyticsPlatform = + initiatingPlatform === "cli" ? "cli" : "desktop"; + await queueServerProductEvent( + shareLinkCreatedEvent({ + videoId: idToUse, + platform: analyticsPlatform, + userId: user.id, + organizationId: videoOrgId, + createdAt, + isScreenshot, + sourceType: videoSource.type, + }), + ).catch(() => { + console.warn( + "Desktop share analytics enqueue failed; reconciliation pending", + ); + }); + const clientSupportsUploadProgress = isFromDesktopSemver( c.req, UPLOAD_PROGRESS_VERSION, diff --git a/apps/web/app/api/events/route.ts b/apps/web/app/api/events/route.ts new file mode 100644 index 00000000000..f32a4e77c1d --- /dev/null +++ b/apps/web/app/api/events/route.ts @@ -0,0 +1,290 @@ +import { + createProductEventRows, + PRODUCT_ANALYTICS_LIMITS, +} from "@cap/analytics"; +import { serverEnv } from "@cap/env"; +import { + hasAnalyticsSessionCookie, + ProductAnalytics, + resolveProductAnalyticsActor, +} from "@cap/web-backend"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, + HttpApiSchema, + HttpServerRequest, +} from "@effect/platform"; +import { Effect, Layer, Schema } from "effect"; +import UAParser from "ua-parser-js"; +import { + readProductAnalyticsBrowserToken, + readProductAnalyticsBrowserTokenClaims, +} from "@/lib/analytics/browser-token"; +import { + classifyAnalyticsTraffic, + getProductAnalyticsRateLimitKey, + hasExpectedBrowserAnalyticsMetadata, + isAllowedAnonymousBrowserProductEvent, + isAuthenticatedAnalyticsRequestCandidate, + normalizeAnalyticsHostname, + normalizeGeoHeader, + normalizeProductEventBatch, + normalizeSyntheticRunId, + ProductAnalyticsRateLimiter, + shouldRejectUnresolvedAuthenticatedAnalyticsRequest, +} 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 }), +) {} + +const DeliveryCount = Schema.Number.pipe( + Schema.int(), + Schema.greaterThanOrEqualTo(0), + Schema.lessThanOrEqualTo(Number.MAX_SAFE_INTEGER), +); + +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), + ), + delivery: Schema.optional( + Schema.Struct({ + attempted: DeliveryCount, + accepted: DeliveryCount, + retried: DeliveryCount, + dropped: DeliveryCount, + queue_overflow: DeliveryCount, + oversize: DeliveryCount, + contract_rejected: Schema.optional(DeliveryCount), + persistence_failed: Schema.optional(DeliveryCount), + }), + ), + }), + ) + .addSuccess( + Schema.Struct({ + accepted: Schema.Number, + acceptedEventIds: Schema.Array(Schema.String), + rejectedEventIds: Schema.Array(Schema.String), + }), + ) + .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), + "x-forwarded-for": Schema.optional(Schema.String), + "user-agent": Schema.optional(Schema.String), + "x-cap-analytics-test-run": 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; + const environment = serverEnv(); + + 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), + environment.NEXTAUTH_SECRET, + ); + if ( + !browserClaims && + !isAuthenticatedAnalyticsRequestCandidate(requestMetadata) + ) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + const isVercel = process.env.VERCEL === "1"; + const trustedNetworkProxy = + isVercel || process.env.CAP_ANALYTICS_TRUST_PROXY === "1"; + if (!trustedNetworkProxy) { + return yield* Effect.fail(new HttpApiError.ServiceUnavailable()); + } + const rateLimitKey = getProductAnalyticsRateLimitKey({ + trustedNetworkProxy, + forwardedFor: + headers[ + isVercel ? "x-vercel-forwarded-for" : "x-forwarded-for" + ], + }); + if (!rateLimitKey) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + if (fallbackRateLimiter.isRateLimited(rateLimitKey)) { + return yield* Effect.fail(new RateLimited()); + } + + const firewallLimited = yield* Effect.promise(() => + Promise.all([ + isRateLimited(RATE_LIMIT_IDS.PRODUCT_ANALYTICS_EVENTS), + ...(browserClaims + ? [ + isRateLimited(RATE_LIMIT_IDS.PRODUCT_ANALYTICS_EVENTS, { + key: `browser:${browserClaims.anonymousId}`, + }), + ] + : []), + ]), + ); + if (firewallLimited.some(Boolean)) { + 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 ( + shouldRejectUnresolvedAuthenticatedAnalyticsRequest({ + actorResolved: Boolean(actor), + authorizationCandidate: + isAuthenticatedAnalyticsRequestCandidate(requestMetadata), + hasSessionCookie: hasAnalyticsSessionCookie(headers.cookie), + }) + ) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + if ( + !actor && + (!browserClaims || + !events.every((event) => + isAllowedAnonymousBrowserProductEvent( + event, + browserClaims.anonymousId, + ), + )) + ) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + const syntheticRunId = normalizeSyntheticRunId( + headers["x-cap-analytics-test-run"], + environment.VERCEL_ENV, + ); + const userAgent = headers["user-agent"] ?? ""; + const parsedUserAgent = new UAParser(userAgent); + 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), + hostname: normalizeAnalyticsHostname(headers.origin), + browser: parsedUserAgent.getBrowser().name ?? "unknown", + device: parsedUserAgent.getDevice().type ?? "desktop", + os: parsedUserAgent.getOS().name ?? "unknown", + trafficClass: classifyAnalyticsTraffic({ + userAgent, + vercelEnvironment: environment.VERCEL_ENV, + syntheticRunId, + rateLimitKey, + internalIpHashes: + process.env.PRODUCT_ANALYTICS_INTERNAL_IP_HASHES, + }), + syntheticRunId, + }); + + const admission = yield* analytics + .appendWithIdentityFence(rows) + .pipe( + Effect.catchAll((error) => + Effect.logWarning("Product analytics ingestion failed").pipe( + Effect.zipRight( + Effect.fail( + "retryable" in error && error.retryable === false + ? new HttpApiError.BadRequest() + : new HttpApiError.ServiceUnavailable(), + ), + ), + ), + ), + ); + if (payload.delivery) { + yield* Effect.logInfo("Product analytics client delivery", { + platform: rows[0]?.platform ?? "unknown", + appVersion: rows[0]?.app_version ?? "", + ...payload.delivery, + }); + } + + const clientEventIds = new Map( + rows.map((row, index) => [row.event_id, events[index]?.eventId]), + ); + const acceptedEventIds = [ + ...new Set( + admission.acceptedEventIds.flatMap( + (eventId) => clientEventIds.get(eventId) ?? [], + ), + ), + ]; + const rejectedEventIds = [ + ...new Set( + admission.rejectedEventIds.flatMap( + (eventId) => clientEventIds.get(eventId) ?? [], + ), + ), + ]; + return { + accepted: acceptedEventIds.length, + acceptedEventIds, + rejectedEventIds, + }; + }), + ); + }), + ), + ), +); + +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..82bd9353faf 100644 --- a/apps/web/app/api/invite/accept/route.ts +++ b/apps/web/app/api/invite/accept/route.ts @@ -7,8 +7,13 @@ import { organizations, users, } from "@cap/database/schema"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; +import { + attemptProductAnalyticsOutboxDelivery, + persistProductAnalyticsEvent, +} from "@/lib/analytics/product-event-outbox"; +import { readAnalyticsAnonymousId } from "@/lib/analytics/server"; import { normalizeAssignableOrganizationRole } from "@/lib/permissions/roles"; import { calculateProSeats, @@ -36,6 +41,10 @@ export async function POST(request: NextRequest) { } try { + let joinedMemberId: string | undefined; + let joinedEventId: string | undefined; + let joinedAt: Date | undefined; + let assignedProSeat = false; await db().transaction(async (tx) => { const [invite] = await tx .select() @@ -46,6 +55,20 @@ export async function POST(request: NextRequest) { if (!invite) { throw new Error("INVITE_NOT_FOUND"); } + const [org] = await tx + .select({ ownerId: organizations.ownerId }) + .from(organizations) + .where( + and( + eq(organizations.id, invite.organizationId), + isNull(organizations.tombstoneAt), + ), + ) + .limit(1) + .for("update"); + if (!org) { + throw new Error("INVITE_NOT_FOUND"); + } if (user.email.toLowerCase() !== invite.invitedEmail.toLowerCase()) { throw new Error("EMAIL_MISMATCH"); @@ -66,6 +89,7 @@ export async function POST(request: NextRequest) { if (!existingMembership) { const newId = nanoId(); + const createdAt = new Date(); const role = normalizeAssignableOrganizationRole(invite.role) ?? "member"; await tx.insert(organizationMembers).values({ @@ -73,17 +97,14 @@ export async function POST(request: NextRequest) { organizationId: invite.organizationId, userId: user.id, role, + createdAt, }); memberId = newId; + joinedMemberId = newId; + joinedAt = createdAt; } - const [org] = await tx - .select({ ownerId: organizations.ownerId }) - .from(organizations) - .where(eq(organizations.id, invite.organizationId)) - .limit(1); - - if (org && memberId && !existingMembership) { + if (memberId && !existingMembership) { const [owner] = await tx .select({ id: users.id, @@ -116,6 +137,7 @@ export async function POST(request: NextRequest) { }); if (proSeatsRemaining > 0) { + assignedProSeat = true; await tx .update(organizationMembers) .set({ hasProSeat: true }) @@ -151,8 +173,29 @@ export async function POST(request: NextRequest) { await tx .delete(organizationInvites) .where(eq(organizationInvites.id, inviteId)); + + if (joinedMemberId && joinedAt) { + joinedEventId = `organization_member:${joinedMemberId}:joined`; + await persistProductAnalyticsEvent(tx, { + eventId: joinedEventId, + eventName: "organization_member_joined", + occurredAt: joinedAt.toISOString(), + anonymousId: readAnalyticsAnonymousId(request), + platform: "web", + userId: user.id, + organizationId: invite.organizationId, + properties: { + role: normalizeAssignableOrganizationRole(invite.role) ?? "member", + assigned_pro_seat: assignedProSeat, + }, + }); + } }); + if (joinedEventId) { + await attemptProductAnalyticsOutboxDelivery(joinedEventId); + } + return NextResponse.json({ success: true }); } catch (error) { if (error instanceof Error) { diff --git a/apps/web/app/api/mobile/[...route]/route.ts b/apps/web/app/api/mobile/[...route]/route.ts index c6296f6bd3b..40465099679 100644 --- a/apps/web/app/api/mobile/[...route]/route.ts +++ b/apps/web/app/api/mobile/[...route]/route.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { PRODUCT_ANALYTICS_DELIVERY_DRAIN_MS } from "@cap/analytics"; import { authOptions } from "@cap/database/auth/auth-options"; import { isEmailAllowedForSignup } from "@cap/database/auth/domain-utils"; import { hashPassword } from "@cap/database/crypto"; @@ -14,6 +15,7 @@ import { getCurrentUser, ImageUploads, Storage, + Tinybird, Videos, VideosRepo, } from "@cap/web-backend"; @@ -55,6 +57,8 @@ import { createMobileContentReport, hasPendingAccountDeletion, } from "@/lib/account-deletion-request"; +import { shareLinkCreatedEvent } from "@/lib/analytics/business-events"; +import { queueServerProductEvent } from "@/lib/analytics/server"; import { queueDesktopSegmentsFinalization } from "@/lib/desktop-segments-finalization"; import { resolveMobileRequestOrigin, @@ -575,6 +579,7 @@ const verifyEmailSession = Effect.fn("Mobile.verifyEmailSession")(function* ({ const requestAccountDeletion = Effect.fn("Mobile.requestAccountDeletion")( function* () { const user = yield* CurrentUser; + const tinybird = yield* Tinybird; yield* Effect.tryPromise({ try: () => createAccountDeletionRequest({ @@ -585,6 +590,10 @@ const requestAccountDeletion = Effect.fn("Mobile.requestAccountDeletion")( }), catch: () => new HttpApiError.InternalServerError(), }); + yield* Effect.sleep(PRODUCT_ANALYTICS_DELIVERY_DRAIN_MS); + yield* tinybird + .eraseProductAnalytics({ userId: user.id }) + .pipe(Effect.mapError(() => new HttpApiError.InternalServerError())); const database = yield* Database; yield* database.use((db) => @@ -2469,9 +2478,19 @@ const createUpload = Effect.fn("Mobile.createUpload")(function* ( width: Option.fromNullable(input.width), height: Option.fromNullable(input.height), duration: Option.fromNullable(input.durationSeconds), - metadata: Option.none(), + metadata: Option.some({ source: "mobileUpload" }), transcriptionStatus: Option.none(), }); + const [createdVideo] = yield* database.use((db) => + db + .select({ createdAt: Db.videos.createdAt, source: Db.videos.source }) + .from(Db.videos) + .where(eq(Db.videos.id, videoId)) + .limit(1), + ); + if (!createdVideo) { + return yield* Effect.fail(new HttpApiError.InternalServerError()); + } yield* database.use((db) => db.insert(Db.videoUploads).values({ @@ -2491,6 +2510,25 @@ const createUpload = Effect.fn("Mobile.createUpload")(function* ( "x-amz-meta-source": "cap-mobile-ios", }, }); + yield* Effect.tryPromise({ + try: () => + queueServerProductEvent( + shareLinkCreatedEvent({ + videoId, + platform: "mobile", + userId: user.id, + organizationId, + createdAt: createdVideo.createdAt, + isScreenshot: false, + sourceType: createdVideo.source.type, + }), + ).catch(() => { + console.warn( + "Mobile share analytics enqueue failed; reconciliation pending", + ); + }), + catch: () => new HttpApiError.InternalServerError(), + }); return { id: videoId, shareUrl: `${publicOrigin}/s/${videoId}`, @@ -2567,6 +2605,16 @@ const createRecording = Effect.fn("Mobile.createRecording")(function* ( metadata: Option.some({ source: "mobileCamera", fps: input.fps }), transcriptionStatus: Option.none(), }); + const [createdVideo] = yield* database.use((db) => + db + .select({ createdAt: Db.videos.createdAt, source: Db.videos.source }) + .from(Db.videos) + .where(eq(Db.videos.id, videoId)) + .limit(1), + ); + if (!createdVideo) { + return yield* Effect.fail(new HttpApiError.InternalServerError()); + } yield* database.use((db) => db.insert(Db.videoUploads).values({ @@ -2575,6 +2623,25 @@ const createRecording = Effect.fn("Mobile.createRecording")(function* ( mode: "singlepart", }), ); + yield* Effect.tryPromise({ + try: () => + queueServerProductEvent( + shareLinkCreatedEvent({ + videoId, + platform: "mobile", + userId: user.id, + organizationId, + createdAt: createdVideo.createdAt, + isScreenshot: false, + sourceType: createdVideo.source.type, + }), + ).catch(() => { + console.warn( + "Mobile share analytics enqueue failed; reconciliation pending", + ); + }), + catch: () => new HttpApiError.InternalServerError(), + }); return { id: videoId, 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..dab816d6af3 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,21 @@ -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 { guestCheckoutStartedEvent } from "@/lib/analytics/business-events"; +import { + queueServerProductEvent, + readAnalyticsAnonymousId, +} from "@/lib/analytics/server"; +import { subscriptionCheckoutAnalyticsMetadata } from "@/lib/analytics/stripe-business-events"; 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 }); @@ -18,6 +26,14 @@ export async function POST(request: NextRequest) { try { console.log("Creating guest checkout session"); + const analyticsMetadata = subscriptionCheckoutAnalyticsMetadata({ + platform: checkoutPlatform, + priceId, + quantity: quantity || 1, + anonymousId: checkoutAnonymousId, + isFirstPurchase: true, + isGuestCheckout: true, + }); const redirects = getCheckoutRedirectUrls( checkoutPlatform, serverEnv().WEB_URL, @@ -28,35 +44,26 @@ export async function POST(request: NextRequest) { success_url: redirects.successUrl, cancel_url: redirects.cancelUrl, allow_promotion_codes: true, - metadata: { - platform: checkoutPlatform, - guestCheckout: "true", - }, + metadata: analyticsMetadata, + subscription_data: { metadata: analyticsMetadata }, }); 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); - } + await queueServerProductEvent( + guestCheckoutStartedEvent({ + checkoutId: checkoutSession.id, + createdAt: new Date(checkoutSession.created * 1_000), + anonymousId: checkoutAnonymousId, + platform: checkoutPlatform, + priceId, + quantity: quantity || 1, + }), + ).catch(() => { + console.warn( + "Guest checkout analytics enqueue failed; reconciliation pending", + ); + }); 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..68e19a62312 100644 --- a/apps/web/app/api/settings/billing/subscribe/route.ts +++ b/apps/web/app/api/settings/billing/subscribe/route.ts @@ -1,17 +1,23 @@ 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 { checkoutStartedEvent } from "@/lib/analytics/business-events"; +import { + queueServerProductEvent, + readAnalyticsAnonymousId, +} from "@/lib/analytics/server"; +import { subscriptionCheckoutAnalyticsMetadata } from "@/lib/analytics/stripe-business-events"; 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"); @@ -63,6 +69,15 @@ export async function POST(request: NextRequest) { customerId = customer.id; } + const analyticsMetadata = subscriptionCheckoutAnalyticsMetadata({ + platform: "web", + priceId, + quantity: quantity ?? 1, + organizationId: user.activeOrganizationId, + anonymousId: analyticsAnonymousId, + isFirstPurchase: !user.stripeSubscriptionId, + isOnboarding: Boolean(isOnBoarding), + }); const checkoutSession = await stripe().checkout.sessions.create({ customer: customerId as string, line_items: [{ price: priceId, quantity: quantity }], @@ -75,32 +90,30 @@ export async function POST(request: NextRequest) { : `${serverEnv().WEB_URL}/pricing`, allow_promotion_codes: true, metadata: { - platform: "web", dubCustomerId: user.id, - isOnBoarding: isOnBoarding ? "true" : "false", + ...analyticsMetadata, }, + subscription_data: { metadata: analyticsMetadata }, }); 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); - } + await queueServerProductEvent( + checkoutStartedEvent({ + checkoutId: checkoutSession.id, + createdAt: new Date(checkoutSession.created * 1_000), + anonymousId: analyticsAnonymousId, + platform: "web", + userId: user.id, + organizationId: user.activeOrganizationId, + priceId, + quantity: quantity ?? 1, + isOnboarding: Boolean(isOnBoarding), + }), + ).catch(() => { + console.warn( + "Checkout analytics enqueue failed; reconciliation pending", + ); + }); return Response.json({ url: checkoutSession.url }, { status: 200 }); } diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index ca9726bbc72..737f6b70183 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -114,6 +114,14 @@ import { runAgentMutation, updateAgentCap, } from "@/lib/agent-write"; +import { + checkoutStartedEvent, + uploadCompletedEvent, + uploadFailedEvent, +} from "@/lib/analytics/business-events"; +import { persistProductAnalyticsEvent } from "@/lib/analytics/product-event-outbox"; +import { queueServerProductEvent } from "@/lib/analytics/server"; +import { subscriptionCheckoutAnalyticsMetadata } from "@/lib/analytics/stripe-business-events"; import { hashKey } from "@/lib/developer-key-hash"; import { startAiGeneration } from "@/lib/generate-ai"; import { probeVideoViaMediaServer } from "@/lib/media-client"; @@ -4340,6 +4348,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( const [account] = yield* database.use((db) => db .select({ + stripeSubscriptionId: Db.users.stripeSubscriptionId, stripeSubscriptionStatus: Db.users.stripeSubscriptionStatus, thirdPartyStripeSubscriptionId: Db.users.thirdPartyStripeSubscriptionId, @@ -4398,14 +4407,23 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( serverEnv().VERCEL_ENV === "production" ? "production" : "development"; + const priceId = + STRIPE_PLAN_IDS[environment][payload.interval]; + const analyticsMetadata = + subscriptionCheckoutAnalyticsMetadata({ + platform: "cli", + priceId, + quantity, + organizationId: path.organizationId, + isFirstPurchase: !account.stripeSubscriptionId, + }); const session = yield* Effect.tryPromise(() => stripe().checkout.sessions.create( { customer: customerId, line_items: [ { - price: - STRIPE_PLAN_IDS[environment][payload.interval], + price: priceId, quantity, }, ], @@ -4414,9 +4432,12 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( cancel_url: `${serverEnv().WEB_URL}/cli/complete?subscription=cancelled`, allow_promotion_codes: true, metadata: { - platform: "agent", organizationId: path.organizationId, userId: principal.id, + ...analyticsMetadata, + }, + subscription_data: { + metadata: analyticsMetadata, }, }, { @@ -4430,6 +4451,19 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( "Subscription checkout is unavailable", ); } + yield* Effect.promise(() => + queueServerProductEvent( + checkoutStartedEvent({ + checkoutId: session.id, + createdAt: new Date(session.created * 1_000), + platform: "cli", + userId: principal.id, + organizationId: path.organizationId, + priceId, + quantity, + }), + ).catch(() => undefined), + ); return { action: "subscription_checkout", url: session.url, @@ -6605,6 +6639,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( fps: payload.fps, metadata: { sourceName: payload.fileName, + initiatingPlatform: payload.initiatingPlatform ?? "server", agentUpload: { state: "pending" }, }, createdAt: now, @@ -6923,6 +6958,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( .select({ id: Db.videos.id, ownerId: Db.videos.ownerId, + organizationId: Db.videos.orgId, metadata: Db.videos.metadata, }) .from(Db.videos) @@ -6957,12 +6993,18 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( .for("update"); if (!upload) return { state: "not_found" as const }; if (!allowed) { + const rejectedAt = new Date(); + const isCliUpload = + lockedVideo.metadata?.initiatingPlatform === "cli"; await tx .update(Db.videos) .set({ metadata: { ...(lockedVideo.metadata ?? {}), - agentUpload: { state: "rejected" }, + agentUpload: { + state: "rejected", + rejectedAt: rejectedAt.toISOString(), + }, }, }) .where(eq(Db.videos.id, path.id)); @@ -6975,8 +7017,34 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( updatedAt: new Date(), }) .where(eq(Db.videoUploads.videoId, path.id)); + if (isCliUpload) { + await persistProductAnalyticsEvent( + tx, + uploadFailedEvent({ + videoId: path.id, + platform: "cli", + userId: lockedVideo.ownerId, + organizationId: lockedVideo.organizationId, + createdAt: rejectedAt, + failureClass: "plan", + }), + ); + } else { + await persistProductAnalyticsEvent( + tx, + uploadFailedEvent({ + videoId: path.id, + platform: "server", + userId: lockedVideo.ownerId, + organizationId: lockedVideo.organizationId, + createdAt: rejectedAt, + failureClass: "plan", + }), + ); + } return { state: "rejected" as const }; } + const acceptedAt = new Date(); await tx .update(Db.videos) .set({ @@ -6988,6 +7056,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( agentUpload: { state: "accepted", rawFileKey: candidateRawFileKey, + acceptedAt: acceptedAt.toISOString(), }, }, }) @@ -7054,6 +7123,8 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( .select({ id: Db.videos.id, ownerId: Db.videos.ownerId, + organizationId: Db.videos.orgId, + createdAt: Db.videos.createdAt, bucketId: Db.videos.bucket, metadata: Db.videos.metadata, }) @@ -7081,6 +7152,33 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( .limit(1) .for("update"); if (!upload) return { state: "not_found" }; + if (video.metadata?.initiatingPlatform === "cli") { + await persistProductAnalyticsEvent( + tx, + uploadCompletedEvent({ + videoId: video.id, + platform: "cli", + userId: video.ownerId, + organizationId: video.organizationId, + createdAt: + video.metadata?.agentUpload?.acceptedAt ?? + video.createdAt, + }), + ); + } else { + await persistProductAnalyticsEvent( + tx, + uploadCompletedEvent({ + videoId: video.id, + platform: "server", + userId: video.ownerId, + organizationId: video.organizationId, + createdAt: + video.metadata?.agentUpload?.acceptedAt ?? + video.createdAt, + }), + ); + } return { state: "success", response: { diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 7bb0ed9322b..6dd51b97bf7 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -1,22 +1,45 @@ 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 { queueServerProductEvent } from "@/lib/analytics/server"; +import { + isFirstPositiveSubscriptionPayment, + isSettledSubscriptionPurchase, + queueSubscriptionCheckoutProductEvent, + queueSubscriptionTrialStartedProductEvent, + subscriptionCancelledProductEvent, + subscriptionChangedProductEvents, + subscriptionInvoicePaidProductEvent, + subscriptionPaymentFailedProductEvent, + subscriptionRefundedProductEvent, + subscriptionTrialConvertedProductEvent, +} from "@/lib/analytics/stripe-business-events"; import { addCreditsToAccount } from "@/lib/developer-credits"; const relevantEvents = new Set([ "checkout.session.completed", "checkout.session.async_payment_succeeded", + "charge.refunded", + "invoice.paid", + "invoice.payment_failed", + "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", ]); +function retryableUserResolutionFailure() { + return new Response("User identity is not available yet", { + status: 503, + headers: { "Retry-After": "60" }, + }); +} + async function grantDeveloperCredits( session: Stripe.Checkout.Session, ): Promise { @@ -121,14 +144,11 @@ async function findUserWithRetry( maxRetries = 3, ): Promise { for (let i = 0; i < maxRetries; i++) { - console.log(`[Attempt ${i + 1}/${maxRetries}] Looking for user:`, { - email, - userId, - }); + console.log(`[Attempt ${i + 1}/${maxRetries}] Looking for Stripe user`); try { if (userId) { - console.log(`Attempting to find user by ID: ${userId}`); + console.log("Attempting to find Stripe user by ID"); const userById = await db() .select() .from(users) @@ -137,14 +157,14 @@ async function findUserWithRetry( .then((rows) => rows[0] ?? null); if (userById) { - console.log(`Found user by ID: ${userId}`); + console.log("Found Stripe user by ID"); return userById; } - console.log(`No user found by ID: ${userId}`); + console.log("No Stripe user found by ID"); } if (email) { - console.log(`Attempting to find user by email: ${email}`); + console.log("Attempting to find Stripe user by email"); const userByEmail = await db() .select() .from(users) @@ -153,10 +173,10 @@ async function findUserWithRetry( .then((rows) => rows[0] ?? null); if (userByEmail) { - console.log(`Found user by email: ${email}`); + console.log("Found Stripe user by email"); return userByEmail; } - console.log(`No user found by email: ${email}`); + console.log("No Stripe user found by email"); } if (i < maxRetries - 1) { @@ -181,6 +201,39 @@ async function findUserWithRetry( return null; } +async function findAnalyticsUserForCustomer( + customerId: string | Stripe.Customer | Stripe.DeletedCustomer | null, +) { + if (!customerId) return null; + const customer = + typeof customerId === "string" + ? await stripe().customers.retrieve(customerId) + : customerId; + if (customer.deleted) return null; + const userId = customer.metadata.userId + ? User.UserId.make(customer.metadata.userId) + : undefined; + return findUserWithRetry(customer.email ?? "", userId, 1); +} + +async function chargeInvoice(charge: Stripe.Charge): Promise { + if (!charge.invoice) { + throw new Error("Subscription refund is missing its Stripe invoice"); + } + return typeof charge.invoice === "string" + ? stripe().invoices.retrieve(charge.invoice) + : charge.invoice; +} + +function invoiceSubscriptionId(invoice: Stripe.Invoice) { + if (!invoice.subscription) { + throw new Error("Subscription invoice is missing its subscription"); + } + return typeof invoice.subscription === "string" + ? invoice.subscription + : invoice.subscription.id; +} + export const POST = async (req: Request) => { console.log("Webhook received"); const buf = await req.text(); @@ -205,6 +258,68 @@ export const POST = async (req: Request) => { if (relevantEvents.has(event.type)) { try { + if (event.type === "invoice.paid") { + const invoice = event.data.object as Stripe.Invoice; + if (!invoice.subscription) return NextResponse.json({ received: true }); + const subscriptionId = invoiceSubscriptionId(invoice); + const dbUser = await findAnalyticsUserForCustomer(invoice.customer); + if (!dbUser) return retryableUserResolutionFailure(); + const invoicePaidProductEvent = subscriptionInvoicePaidProductEvent({ + eventId: event.id, + occurredAt: new Date(event.created * 1000).toISOString(), + invoice, + user: dbUser, + firstPositivePayment: await isFirstPositiveSubscriptionPayment({ + invoice, + subscriptionId, + listPaidInvoices: (input) => stripe().invoices.list(input), + }), + }); + if (invoicePaidProductEvent) + await queueServerProductEvent(invoicePaidProductEvent); + } + + if (event.type === "invoice.payment_failed") { + const invoice = event.data.object as Stripe.Invoice; + if (invoice.subscription) { + const dbUser = await findAnalyticsUserForCustomer(invoice.customer); + if (!dbUser) return retryableUserResolutionFailure(); + const paymentFailedProductEvent = + subscriptionPaymentFailedProductEvent({ + eventId: event.id, + occurredAt: new Date(event.created * 1000).toISOString(), + invoice, + user: dbUser, + }); + if (paymentFailedProductEvent) + await queueServerProductEvent(paymentFailedProductEvent); + } + } + + if (event.type === "charge.refunded") { + const charge = event.data.object as Stripe.Charge; + const previousCharge = event.data.previous_attributes as + | Partial + | undefined; + const previousAmountRefunded = previousCharge?.amount_refunded ?? 0; + const refundedAmount = charge.amount_refunded - previousAmountRefunded; + if (charge.invoice && refundedAmount > 0) { + const invoice = await chargeInvoice(charge); + const dbUser = await findAnalyticsUserForCustomer(charge.customer); + if (!dbUser) return retryableUserResolutionFailure(); + const refundProductEvent = subscriptionRefundedProductEvent({ + eventId: event.id, + occurredAt: new Date(event.created * 1000).toISOString(), + charge, + invoice, + user: dbUser, + refundedAmount, + }); + if (refundProductEvent) + await queueServerProductEvent(refundProductEvent); + } + } + if (event.type === "checkout.session.completed") { console.log("Processing checkout.session.completed event"); const session = event.data.object as Stripe.Checkout.Session; @@ -221,11 +336,7 @@ export const POST = async (req: Request) => { const customer = await stripe().customers.retrieve( session.customer as string, ); - console.log("Retrieved customer:", { - id: customer.id, - email: "email" in customer ? customer.email : undefined, - metadata: "metadata" in customer ? customer.metadata : undefined, - }); + console.log("Retrieved Stripe customer"); let foundUserId: User.UserId | undefined; let customerEmail: string | null | undefined; @@ -239,10 +350,7 @@ export const POST = async (req: Request) => { customerEmail = customer.email; } - console.log("Starting user lookup with:", { - foundUserId, - customerEmail, - }); + console.log("Starting Stripe user lookup"); let dbUser = await findUserWithRetry( customerEmail as string, @@ -259,10 +367,7 @@ export const POST = async (req: Request) => { }); } - console.log( - "Guest checkout detected, creating new user with email:", - guestEmail, - ); + console.log("Guest checkout detected, creating new user"); try { dbUser = await createGuestUser(guestEmail); @@ -279,19 +384,11 @@ export const POST = async (req: Request) => { } if (!dbUser) { - console.log( - "No user found after all retries. Returning 202 to allow retry.", - ); - return new Response("User not found, webhook will be retried", { - status: 202, - }); + console.error("No user found after all checkout retries"); + return retryableUserResolutionFailure(); } - console.log("Successfully found user:", { - userId: dbUser.id, - email: dbUser.email, - name: dbUser.name, - }); + console.log("Successfully found Stripe user"); const subscription = await stripe().subscriptions.retrieve( session.subscription as string, @@ -313,7 +410,6 @@ export const POST = async (req: Request) => { customerId: customer.id, inviteQuota, }); - console.log("Session metadata:", session.metadata); console.log("Is onboarding:", isOnBoarding); await db() @@ -329,39 +425,13 @@ 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)) { + await queueSubscriptionCheckoutProductEvent({ + eventId: event.id, + occurredAt: new Date(event.created * 1000).toISOString(), + session, + user: dbUser, }); - - await serverPostHog.shutdown(); - console.log("Successfully tracked purchase event in PostHog"); - } catch (error) { - console.error("Error tracking purchase in PostHog:", error); } } @@ -374,11 +444,74 @@ export const POST = async (req: Request) => { if (session.metadata?.type === "developer_credits") { return await grantDeveloperCredits(session); } + + if (typeof session.subscription === "string") { + if (isSettledSubscriptionPurchase(session)) { + 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, + ); + } + } + if (!dbUser) { + console.error("No user found for settled asynchronous checkout"); + return retryableUserResolutionFailure(); + } + + await queueSubscriptionCheckoutProductEvent({ + eventId: event.id, + occurredAt: new Date(event.created * 1000).toISOString(), + session, + user: dbUser, + }); + } + } + } + + if (event.type === "customer.subscription.created") { + const subscription = event.data.object as Stripe.Subscription; + if ( + subscription.status === "trialing" && + subscription.metadata.analyticsSchemaVersion + ) { + const customer = await stripe().customers.retrieve( + subscription.customer as string, + ); + if (customer.deleted) return retryableUserResolutionFailure(); + const userId = customer.metadata.userId + ? User.UserId.make(customer.metadata.userId) + : undefined; + const dbUser = await findUserWithRetry( + customer.email ?? "", + userId, + 1, + ); + if (!dbUser) return retryableUserResolutionFailure(); + await queueSubscriptionTrialStartedProductEvent({ + eventId: event.id, + occurredAt: new Date(event.created * 1_000).toISOString(), + subscription, + user: dbUser, + }); + } } if (event.type === "customer.subscription.updated") { console.log("Processing customer.subscription.updated event"); const subscription = event.data.object as Stripe.Subscription; + const previous = event.data.previous_attributes as + | Partial + | undefined; console.log("Subscription data:", { id: subscription.id, status: subscription.status, @@ -388,11 +521,7 @@ export const POST = async (req: Request) => { const customer = await stripe().customers.retrieve( subscription.customer as string, ); - console.log("Retrieved customer:", { - id: customer.id, - email: "email" in customer ? customer.email : undefined, - metadata: "metadata" in customer ? customer.metadata : undefined, - }); + console.log("Retrieved Stripe customer"); let foundUserId: User.UserId | undefined; let customerEmail: string | null | undefined; @@ -406,10 +535,7 @@ export const POST = async (req: Request) => { customerEmail = customer.email; } - console.log("Starting user lookup with:", { - foundUserId, - customerEmail, - }); + console.log("Starting Stripe user lookup"); const dbUser = await findUserWithRetry( customerEmail as string, @@ -417,19 +543,11 @@ export const POST = async (req: Request) => { ); if (!dbUser) { - console.log( - "No user found after all retries. Returning 202 to allow retry.", - ); - return new Response("User not found, webhook will be retried", { - status: 202, - }); + console.error("No user found after all subscription retries"); + return retryableUserResolutionFailure(); } - console.log("Successfully found user:", { - userId: dbUser.id, - email: dbUser.email, - name: dbUser.name, - }); + console.log("Successfully found Stripe user"); const subscriptions = await stripe().subscriptions.list({ customer: customer.id, @@ -467,6 +585,25 @@ export const POST = async (req: Request) => { }) .where(eq(users.id, dbUser.id)); + const occurredAt = new Date(event.created * 1000).toISOString(); + const trialConverted = subscriptionTrialConvertedProductEvent({ + eventId: event.id, + occurredAt, + subscription, + previousStatus: previous?.status, + user: dbUser, + }); + if (trialConverted) await queueServerProductEvent(trialConverted); + for (const productEvent of subscriptionChangedProductEvents({ + eventId: event.id, + occurredAt, + subscription, + previous, + user: dbUser, + })) { + await queueServerProductEvent(productEvent); + } + console.log( "Successfully updated user in database with new invite quota:", inviteQuota, @@ -495,7 +632,7 @@ export const POST = async (req: Request) => { if (userByEmail && userByEmail.length > 0 && userByEmail[0]) { foundUserId = userByEmail[0].id; - console.log(`User found by email: ${foundUserId}`); + console.log("Stripe user found by email"); await stripe().customers.update(customer.id, { metadata: { userId: foundUserId }, }); @@ -532,6 +669,15 @@ export const POST = async (req: Request) => { }) .where(eq(users.id, foundUserId)); + await queueServerProductEvent( + subscriptionCancelledProductEvent({ + eventId: event.id, + occurredAt: new Date(event.created * 1000).toISOString(), + subscription, + user: { id: foundUserId }, + }), + ); + console.log("User updated successfully", { foundUserId, inviteQuota: 1, diff --git a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx index e2a9a2dfebb..ec16f3902be 100644 --- a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx +++ b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx @@ -50,7 +50,10 @@ type CommentWithAuthor = typeof commentsSchema.$inferSelect & { export const EmbedVideo = forwardRef< HTMLVideoElement, { - data: Omit & { + data: Omit< + typeof videos.$inferSelect, + "firstExternalViewAt" | "password" + > & { hasActiveUpload: boolean | undefined; }; user: typeof userSelectProps | null; diff --git a/apps/web/app/embed/[videoId]/page.tsx b/apps/web/app/embed/[videoId]/page.tsx index e6bb947e850..3a29e553b5d 100644 --- a/apps/web/app/embed/[videoId]/page.tsx +++ b/apps/web/app/embed/[videoId]/page.tsx @@ -185,7 +185,10 @@ async function EmbedContent({ autoplay, minimal, }: { - video: Omit & { + video: Omit< + typeof videos.$inferSelect, + "firstExternalViewAt" | "password" + > & { sharedOrganization: { organizationId: Organisation.OrganisationId } | null; hasActiveUpload: boolean | undefined; orgSettings?: (typeof organizations.$inferSelect)["settings"] | null; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f2c9726ccc1..9ba2c7a0b96 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,7 +2,8 @@ import "@/app/globals.css"; import type { Metadata } from "next"; import localFont from "next/font/local"; import Script from "next/script"; -import type { PropsWithChildren } from "react"; +import { type PropsWithChildren, Suspense } from "react"; +import { ProductAnalyticsPageView } from "./Layout/ProductAnalyticsPageView"; const defaultFont = localFont({ src: [ @@ -99,6 +100,9 @@ export default function RootLayout({ children }: PropsWithChildren) { ', + "https://preview.vercel.app", + ), + ["https://preview.vercel.app/_next/static/a.js"], + ); +}); + +test("bundle budgets require absolute and live-baseline gates", () => { + assert.deepEqual( + evaluateBundleBudget({ + baselineBytes: 1_000_000, + measuredBytes: 1_040_000, + absoluteMaximumBytes: 5_000_000, + regressionFactor: 1.05, + regressionFloorBytes: 25_000, + }), + { + absoluteMaximumBytes: 5_000_000, + regressionLimitBytes: 1_050_000, + deltaBytes: 40_000, + regressionRatio: 1.04, + passed: true, + }, + ); + assert.equal( + evaluateBundleBudget({ + baselineBytes: 1_000_000, + measuredBytes: 1_200_000, + absoluteMaximumBytes: 5_000_000, + regressionFactor: 1.05, + regressionFloorBytes: 25_000, + }).passed, + false, + ); +}); + +test("latency budgets require both absolute and measured-baseline gates", () => { + const baseline = latencySummary([80, 90, 100, 110, 120]); + assert.deepEqual( + evaluateLatencyBudget({ + baseline, + measured: latencySummary([90, 100, 110, 120, 130]), + absoluteP95Ms: 2_500, + regressionFactor: 2.5, + regressionFloorMs: 250, + }), + { + absoluteP95Ms: 2_500, + regressionLimitMs: 370, + regressionRatio: 130 / 120, + passed: true, + }, + ); + assert.equal( + evaluateLatencyBudget({ + baseline, + measured: latencySummary([500]), + absoluteP95Ms: 2_500, + regressionFactor: 2.5, + regressionFloorMs: 250, + }).passed, + false, + ); +}); + +test("Copy performance budgets gate visibility and pipeline regressions", () => { + const baseline = { + pipelineWallClockMs: 80_000, + visibility: latencySummary([5_000, 10_000, 20_000]), + }; + assert.deepEqual( + evaluateCopyPerformanceBudget({ + absolutePipelineMs: 600_000, + absoluteVisibilityP95Ms: 120_000, + baseline, + measured: { + pipelineWallClockMs: 100_000, + visibility: latencySummary([10_000, 20_000, 30_000]), + }, + regressionFactor: 2, + regressionFloorMs: 30_000, + }), + { + mode: "baseline_comparison", + absolutePipelineMs: 600_000, + absoluteVisibilityP95Ms: 120_000, + regressionFactor: 2, + regressionFloorMs: 30_000, + pipelineRegressionLimitMs: 160_000, + visibilityRegressionLimitMs: 50_000, + pipelineRegressionRatio: 1.25, + visibilityRegressionRatio: 1.5, + passed: true, + }, + ); + assert.equal( + evaluateCopyPerformanceBudget({ + absolutePipelineMs: 600_000, + absoluteVisibilityP95Ms: 120_000, + baseline, + measured: { + pipelineWallClockMs: 200_000, + visibility: latencySummary([60_000]), + }, + regressionFactor: 2, + regressionFloorMs: 30_000, + }).passed, + false, + ); + assert.equal( + evaluateCopyPerformanceBudget({ + absolutePipelineMs: 600_000, + absoluteVisibilityP95Ms: 120_000, + baseline: null, + measured: { + pipelineWallClockMs: 80_000, + visibility: latencySummary([20_000]), + }, + regressionFactor: 2, + regressionFloorMs: 30_000, + }).mode, + "baseline_capture", + ); +}); + +test("CI assertion normalization proves decision deduplication and conflict quarantine", () => { + const assertions = normalizeCiAssertions({ + data: [ + { + received_rows: "4", + unique_events: "2", + unique_payloads: "3", + duplicate_rows: "1", + payload_conflicts: "1", + canonical_events: "1", + decision_events: "1", + }, + ], + }); + assert.doesNotThrow(() => assertSyntheticDecisions(assertions)); + assert.throws(() => + assertSyntheticDecisions({ ...assertions, decisionEvents: 2 }), + ); +}); + +test("copy assertion normalization exposes every marker independently", () => { + assert.deepEqual( + normalizeCopyAssertions({ + data: [ + { + decision_markers: "1", + traffic_markers: "1", + traffic_page_markers: "1", + activation_markers: "1", + retention_markers: "1", + identity_markers: "1", + attribution_markers: "1", + experiment_markers: "1", + health_markers: "1", + }, + ], + }), + { + decisionMarkers: 1, + trafficMarkers: 1, + trafficPageMarkers: 1, + activationMarkers: 1, + retentionMarkers: 1, + identityMarkers: 1, + attributionMarkers: 1, + experimentMarkers: 1, + healthMarkers: 1, + }, + ); +}); + +test("the analytics workflow is statically restricted to staging", () => { + const workflow = fs.readFileSync( + new URL("../../../.github/workflows/analytics.yml", import.meta.url), + "utf8", + ); + assert.doesNotThrow(() => assertWorkflowSafety(workflow)); + const reorderedWorkflow = workflow + .replace("staging-ci.js seed", "staging-ci.js __seed_placeholder__") + .replace("staging-ci.js promote-deployment", "staging-ci.js seed") + .replace( + "staging-ci.js __seed_placeholder__", + "staging-ci.js promote-deployment", + ); + assert.throws(() => assertWorkflowSafety(reorderedWorkflow)); + assert.equal( + workflow.match( + /deployment create --allow-destructive-operations --(?:check|wait)/g, + )?.length, + 2, + ); + assert.equal( + workflow.match(/node scripts\/analytics\/staging-ci\.js run-copies/g) + ?.length, + 2, + ); + assert.equal( + workflow.match( + /--deployment-id "\$\{\{ steps\.tinybird\.outputs\.id \}\}"/g, + )?.length, + 15, + ); + assert.doesNotMatch(workflow, /tinybird-cloud-cli --cloud copy run/); + assert.ok( + workflow.indexOf("Refuse a preview bound outside Tinybird staging") < + workflow.indexOf("Record Tinybird deployment boundary"), + ); + assert.match( + workflow, + /Refuse a preview bound outside Tinybird staging[\s\S]*CAP_ANALYTICS_STAGING_TEST_SECRET[\s\S]*staging-ci\.js attest-preview/, + ); + assert.match( + workflow, + /ANALYTICS_PREVIEW_ACCESS_URL: https:\/\/cap-web-git-codex-first-party-analytics-mc-ilroy\.vercel\.app/, + ); + assert.match( + workflow, + /Refuse a preview bound outside Tinybird staging[\s\S]*VERCEL_PREVIEW_SHARE_SECRET[\s\S]*staging-ci\.js attest-preview/, + ); + assert.ok( + workflow.indexOf( + "Prove promoted delivery, business values, and decision deduplication", + ) < workflow.indexOf("Prove exact staging rollback and restoration"), + ); + assert.ok( + workflow.indexOf("Prove exact staging rollback and restoration") < + workflow.indexOf( + "Delete the scoped identity through the exact-SHA application path", + ), + ); + assert.ok( + workflow.indexOf( + "Delete the scoped identity through the exact-SHA application path", + ) < + workflow.indexOf( + "Prove identity erasure and out-of-scope control preservation", + ), + ); + assert.ok( + workflow.indexOf( + "Prove identity erasure and out-of-scope control preservation", + ) < + workflow.indexOf( + "Quiesce scheduled and active Copy jobs before final cleanup", + ), + ); + assert.match( + workflow, + /Delete the scoped identity through the exact-SHA application path[\s\S]*CAP_ANALYTICS_STAGING_TEST_SECRET[\s\S]*staging-ci\.js erase-synthetic-identity/, + ); + assert.ok( + workflow.indexOf("Prove synthetic cleanup no longer affects queries") < + workflow.indexOf("Resume reviewed Copy schedules"), + ); + assert.ok( + workflow.indexOf("Resume reviewed Copy schedules") < + workflow.indexOf("Finalize the fully verified staging promotion"), + ); + assert.ok( + workflow.indexOf("Finalize the fully verified staging promotion") < + workflow.indexOf("Restore the previous staging deployment on failure"), + ); + assert.match(workflow, /steps\.promote\.outcome == 'failure'/); + assert.match(workflow, /continue-on-error: true/); + assert.match(workflow, /staging-ci\.js resolve-deployment-state/); + assert.match(workflow, /resolution_exit=\$\?/); + assert.match(workflow, /"\$resolution_exit" -ne 75/); + assert.match(workflow, /--recover-pending "\$recover_pending"/); + assert.match(workflow, /recover_pending=true/); + assert.match( + workflow, + /Refuse to proceed without an authoritative live deployment/, + ); + assert.match( + workflow, + /Discard an unpromoted staging deployment after cleanup\n {8}id: discard\n {8}if: always\(\) && steps\.rollback\.outcome != 'success' && \(steps\.deployment-state\.outputs\.discard == 'true' \|\| steps\.cleanup\.outputs\.requires_discard == 'true'\)/, + ); + assert.match( + workflow, + /staging-ci\.js promote-deployment --deployment-id "\$\{\{ steps\.tinybird\.outputs\.id \}\}"/, + ); + assert.match(workflow, /staging-ci\.js discard-deployment/); + assert.match(workflow, /staging-ci\.js drill-rollback/); + assert.match( + workflow, + /Prove exact staging rollback and restoration[\s\S]*TINYBIRD_STAGING_READ_TOKEN[\s\S]*--state "\$RUNNER_TEMP\/analytics-staging-state\.json"/, + ); + assert.match(workflow, /staging-ci\.js finalize-promotion/); + assert.match(workflow, /staging-ci\.js rollback-promotion/); + assert.match( + workflow, + /steps\.promote\.outputs\.previous_live_id != '' && steps\.finalize\.outcome != 'success' && steps\.rollback-drill\.outputs\.rollback_target_usable != 'false' && \(steps\.seed\.outcome == 'skipped' \|\| steps\.verify-cleanup\.outcome == 'success'\)/, + ); + assert.doesNotMatch( + workflow, + /tinybird-cloud-cli --cloud deployment promote/, + ); + assert.doesNotMatch( + workflow, + /tinybird-cloud-cli --cloud deployment discard/, + ); + assert.equal( + workflow.match( + /steps\.deployment-state\.outputs\.target \|\| \(steps\.tinybird\.outputs\.needs_promotion == 'true' && 'staging' \|\| 'live'\)/g, + )?.length, + 1, + ); + assert.equal( + workflow.match( + /--target "\$\{\{ steps\.tinybird\.outputs\.needs_promotion == 'true' && 'staging' \|\| 'live' \}\}"/g, + )?.length, + 1, + ); + assert.match( + workflow, + /Prove exact candidate endpoints before promotion[\s\S]*staging-ci\.js verify-preseed/, + ); + assert.ok( + workflow.indexOf("Prove exact candidate endpoints before promotion") < + workflow.indexOf("Promote the verified staging deployment"), + ); + assert.ok( + workflow.indexOf( + "Refuse to proceed without an authoritative live deployment", + ) < + workflow.indexOf( + "Seed bounded duplicate and conflict probes into exact live staging", + ), + ); + assert.ok( + workflow.indexOf( + "Seed bounded duplicate and conflict probes into exact live staging", + ) < workflow.indexOf("Prove least-privilege staging token scopes"), + ); + assert.match(workflow, /35-postseed/); + assert.ok( + workflow.indexOf("Prove the exact-SHA deployed browser tracker") < + workflow.indexOf( + "Probe the exact-SHA Vercel browser collector and staging rate limit", + ), + ); + assert.ok( + workflow.indexOf( + "Probe the exact-SHA Vercel browser collector and staging rate limit", + ) < workflow.indexOf("Prove exact-SHA durable server delivery"), + ); + assert.ok( + workflow.indexOf("Prove exact-SHA durable server delivery") < + workflow.indexOf("Rebuild promoted decision and health copies"), + ); + assert.match( + workflow, + /Prove exact-SHA durable server delivery[\s\S]*CAP_ANALYTICS_STAGING_TEST_SECRET[\s\S]*staging-ci\.js probe-server/, + ); + assert.ok( + workflow.indexOf("Rebuild promoted decision and health copies") < + workflow.indexOf("Measure populated decision endpoint performance"), + ); + assert.match( + workflow, + /Rebuild promoted decision and health copies[\s\S]{0,600}TINYBIRD_STAGING_SCHEDULER_TOKEN:[\s\S]{0,400}staging-ci\.js run-copies/, + ); + assert.match( + workflow, + /Retract synthetic rows from every derived copy[\s\S]{0,600}TINYBIRD_STAGING_SCHEDULER_TOKEN:[\s\S]{0,400}staging-ci\.js run-copies/, + ); + assert.ok( + workflow.indexOf("Measure populated decision endpoint performance") < + workflow.indexOf( + "Prove promoted delivery, business values, and decision deduplication", + ), + ); + assert.match( + workflow, + /Measure populated decision endpoint performance[\s\S]*staging-ci\.js verify[\s\S]*--target live/, + ); + assert.match( + workflow, + /--baseline-deployment-id "\$\{\{ steps\.promote\.outputs\.previous_live_id \|\| steps\.tinybird\.outputs\.id \}\}"/, + ); + assert.match( + workflow, + /Quiesce scheduled and active Copy jobs before final cleanup\n {8}id: pause-copies\n {8}if: always\(\) && steps\.seed\.outcome != 'skipped' && steps\.deployment-state\.outputs\.promoted == 'true'/, + ); + assert.match( + workflow, + /Delete strictly scoped synthetic raw rows\n {8}id: cleanup\n {8}if: always\(\) && steps\.seed\.outcome != 'skipped' && \(steps\.deployment-state\.outputs\.target == 'staging' \|\| steps\.pause-copies\.outcome == 'success'\)/, + ); + assert.match( + workflow, + /Resume reviewed Copy schedules\n {8}id: resume-copies\n {8}if: always\(\) && steps\.pause-copies\.outcome == 'success'/, + ); + assert.match(workflow, /steps\.cleanup\.outputs\.requires_copies == 'true'/); + assert.match( + workflow, + /Upload the immutable post-ingestion recovery checkpoint\n {8}if: steps\.seed\.outcome == 'success'/, + ); + assert.match(workflow, /echo "required=false" >> "\$GITHUB_OUTPUT"/); + assert.match(workflow, /echo "required=true" >> "\$GITHUB_OUTPUT"/); + assert.match( + workflow, + /Upload redacted staging evidence\n {8}if: always\(\) && steps\.cleanup\.outputs\.required == 'true'/, + ); + assert.ok( + workflow.indexOf("Retire only a superseded Tinybird staging predecessor") < + workflow.indexOf("Persist the pre-create Tinybird recovery boundary"), + ); + assert.ok( + workflow.indexOf("Upload the immutable pre-create recovery boundary") < + workflow.indexOf("Create isolated Tinybird staging deployment"), + ); + assert.match( + workflow, + /staging-ci\.js discard-retired-deployment[\s\S]*analytics-retired-deployment\.json/, + ); + assert.ok( + workflow.indexOf("Upload the immutable pre-ingestion recovery checkpoint") < + workflow.indexOf("Seed bounded duplicate and conflict probes"), + ); + assert.ok( + workflow.indexOf("Upload the immutable pre-promotion recovery checkpoint") < + workflow.indexOf("Promote the verified staging deployment"), + ); + assert.match( + workflow, + /recover-staging:[\s\S]*needs: deploy-staging[\s\S]*if: always\(\) && needs\.deploy-staging\.result != 'success'/, + ); + assert.match(workflow, /permissions:[\s\S]*actions: read/); + assert.match( + workflow, + /actions\/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093/, + ); + assert.doesNotMatch(workflow, /actions\/(?:upload|download)-artifact@v\d/); + assert.match(workflow, /staging-ci\.js recover/); +}); + +test("the preview mutation route independently enforces Tinybird staging", () => { + const route = fs.readFileSync( + new URL( + "../../../apps/web/app/api/analytics/staging-test/[[...route]]/route.ts", + import.meta.url, + ), + "utf8", + ); + assert.match( + route, + /const TINYBIRD_STAGING_ORIGIN = "https:\/\/api\.us-east\.aws\.tinybird\.co"/, + ); + assert.match( + route, + /const TINYBIRD_STAGING_WORKSPACE_ID =\s*"37b8fef9-817f-4c3c-b21f-218c36a6077d"/, + ); + assert.match( + route, + /const authorize = [\s\S]*!configurationAttestation\(runId\)/, + ); + assert.doesNotMatch(route, /CAP_ANALYTICS_STAGING_PREVIEW/); + assert.doesNotMatch(route, /process\.env\.VERCEL_ENV/); + assert.match(route, /"x-cap-analytics-staging-signature"/); + assert.match( + route, + /createHmac\("sha256", secret\)[\s\S]*\.update\(`\$\{runId\}:\$\{payload\.sha\}`\)/, + ); + assert.match( + route, + /const STAGING_DATABASE_FINGERPRINT =\s*"fff37a9b160f31bfb82b8c5585829b8ee08f70b3645169dca6e7cb29033a039a"/, + ); + assert.match( + route, + /databaseSchema: Schema\.Literal\("0042_lying_sharon_ventura"\)/, + ); + assert.match(route, /HttpApiEndpoint\.post\("health"/); + assert.match(route, /const scopedDatabaseHealth = async/); + assert.match(route, /FROM information_schema\.STATISTICS/); + assert.match(route, /product_analytics_ingestion_leases:fencingToken/); + assert.match(route, /product_analytics_refresh_leases:name/); + assert.match(route, /product_analytics_outbox:delivery_idx:1:3:createdAt/); + assert.match(route, /\/api\/analytics\/staging-test\/cleanup-database/); + + const runner = fs.readFileSync( + new URL("../staging-ci.js", import.meta.url), + "utf8", + ); + assert.match( + runner, + /const STAGING_PREVIEW_ACCESS_ORIGIN =\s*"https:\/\/cap-web-git-codex-first-party-analytics-mc-ilroy\.vercel\.app"/, + ); + assert.match( + runner, + /shareUrl\.searchParams\.set\("_vercel_share", shareSecret\)/, + ); + assert.match( + runner, + /const handshake = await fetch\(shareUrl, \{[\s\S]*method: "GET",[\s\S]*redirect: "manual"/, + ); + assert.match(runner, /value\.startsWith\("_vercel_jwt="\)/); + assert.match( + runner, + /artifact\.vercel\.accessUrl \?\? artifact\.vercel\.url/, + ); + assert.match(runner, /"x-cap-analytics-staging-signature"/); + assert.match( + runner, + /pathname !== "\/api\/analytics\/staging-test" &&[\s\S]*!pathname\.startsWith\("\/api\/analytics\/staging-test\/"\)/, + ); + assert.match(runner, /accepted an invalid request signature with HTTP/); + const browserProbe = fs.readFileSync( + new URL( + "../../../apps/chrome-extension/e2e/analytics-staging.spec.ts", + import.meta.url, + ), + "utf8", + ); + assert.equal(browserProbe.match(/await attestExactSha\(\)/g)?.length, 2); + assert.match(browserProbe, /createHmac\("sha256", stagingSecret\)/); + assert.match(browserProbe, /"x-cap-analytics-staging-signature"/); + assert.match( + browserProbe, + /shareUrl\.searchParams\.set\("_vercel_share", shareSecret\)/, + ); + const serverProbe = runner.slice( + runner.indexOf("const probeDurableServerPath = async () => {"), + runner.indexOf("const seed = async () => {"), + ); + assert.match(serverProbe, /\/api\/analytics\/staging-test\/health/); + assert.match(serverProbe, /Number\(outboxHealth\.activeEvents\) !== 0/); + assert.match(serverProbe, /Number\(outboxHealth\.deadLetterEvents\) !== 1/); + assert.match(serverProbe, /durableOutboxHealthPassed: true/); +}); + +test("the seed checkpoint is persisted before ingestion", () => { + const source = fs.readFileSync( + new URL("../staging-ci.js", import.meta.url), + "utf8", + ); + const prepareSource = source.slice( + source.indexOf("const prepareSeed = async () => {"), + source.indexOf("const seed = async () => {"), + ); + const seedSource = source.slice( + source.indexOf("const seed = async () => {"), + source.indexOf("const waitForCopyVisibility"), + ); + const stateWrite = prepareSource.indexOf( + "writeJson(statePath, state, 0o600)", + ); + const artifactWrite = prepareSource.indexOf( + "writeJson(artifactPath, artifact)", + ); + const firstDelivery = seedSource.indexOf("const concurrentDeliveries"); + assert.ok(stateWrite >= 0); + assert.ok(artifactWrite > stateWrite); + assert.ok(firstDelivery >= 0); + assert.match(prepareSource, /recoveryPhase: "preseed"/); + assert.match(prepareSource, /assertions: \{ seedAccepted: false \}/); + assert.match(prepareSource, /rowsPlanned: fixture\.rows\.length/); + assert.match(prepareSource, /rowsAttempted: 0/); + assert.match( + seedSource, + /tinybirdEnvironment\(\[\s*"TINYBIRD_STAGING_DEPLOY_TOKEN",\s*"TINYBIRD_STAGING_INGEST_TOKEN",?\s*\]\)/, + ); + assert.doesNotMatch(seedSource, /TINYBIRD_STAGING_COPY_TOKEN/); + assert.match(seedSource, /assertExactLiveOwnership/); + assert.match(seedSource, /state\.recoveryPhase = "postseed"/); + assert.ok( + seedSource.indexOf("artifact.delivery.rowsAttempted += 1") < + seedSource.indexOf("const result = await request"), + ); + assert.match( + seedSource, + /artifact\.delivery\.rowsAccepted \+= 1;[\s\S]*writeJson\(artifactPath, artifact\);/, + ); +}); + +test("candidate validation performs no synthetic writes before promotion", () => { + const source = fs.readFileSync( + new URL("../staging-ci.js", import.meta.url), + "utf8", + ); + const preSeedSource = source.slice( + source.indexOf("const verifyPreSeedDeployment = async"), + source.indexOf("const verify = async"), + ); + assert.match(preSeedSource, /strategy: "promote_then_seed"/); + assert.match(preSeedSource, /candidatePreSeedCleanPassed: true/); + assert.doesNotMatch(preSeedSource, /\/v0\/events/); + assert.doesNotMatch(preSeedSource, /TINYBIRD_STAGING_INGEST_TOKEN/); +}); + +test("recovery avoids unowned schedule changes and publishes failure evidence", () => { + const source = fs.readFileSync( + new URL("../staging-ci.js", import.meta.url), + "utf8", + ); + const recoverySource = source.slice( + source.indexOf("const recoverStaging = async () => {"), + source.indexOf("const handlers ="), + ); + assert.ok( + recoverySource.indexOf('strategy: "incomplete"') < + recoverySource.indexOf("recoveryCheckpoint("), + ); + assert.match( + recoverySource, + /artifact\.copySchedule\?\.pause\?\.status === "passed" &&[\s\S]*pausedDeploymentId === retainedDeploymentId[\s\S]*await setCopySchedules/, + ); + assert.match( + recoverySource, + /previousLifecycle === "ready"[\s\S]*action: "pause"[\s\S]*await cleanup\([\s\S]*await runCopies\([\s\S]*action: "resume"[\s\S]*await switchLiveDeployment/, + ); + assert.match( + recoverySource, + /candidateLifecycle === "ready"[\s\S]*target: "staging"[\s\S]*syntheticCleanupCompleted = true/, + ); +}); + +test("candidate cleanup refuses a live transition before any deletion", () => { + const source = fs.readFileSync( + new URL("../staging-ci.js", import.meta.url), + "utf8", + ); + const cleanupSource = source.slice( + source.indexOf("const cleanup = async"), + source.indexOf("const verifyPromoted = async"), + ); + const targetGuard = cleanupSource.indexOf("if (target !== requestedTarget)"); + assert.ok(targetGuard >= 0); + assert.ok(targetGuard < cleanupSource.indexOf("cleanupPreviewDatabaseState")); + assert.ok(targetGuard < cleanupSource.indexOf("deleteProductEventRows")); + assert.match( + cleanupSource, + /Tinybird cleanup target changed before scoped cleanup/, + ); + assert.match( + cleanupSource, + /if \(target === "staging"\)[\s\S]*liveBeforeDeploymentId[\s\S]*deleteProductEventRows[\s\S]*liveSyntheticRowsDeleted: true/, + ); +}); + +test("event state reaches terminal success before canonical rebuilding", () => { + const source = fs.readFileSync( + new URL("../staging-ci.js", import.meta.url), + "utf8", + ); + const runCopiesSource = source.slice( + source.indexOf("const runCopies = async"), + source.indexOf("const verify = async"), + ); + const stateCopy = runCopiesSource.indexOf( + '"snapshot_product_event_id_states_v2",\n\t\t\t\t"snapshot_product_event_day_states_v2",', + ); + const stateWait = runCopiesSource.indexOf("await waitForCopyJobs"); + const canonicalCopy = runCopiesSource.indexOf( + 'pipes: ["snapshot_product_events_canonical_v1"]', + ); + assert.ok(stateCopy >= 0); + assert.ok(stateCopy < stateWait); + assert.ok(stateWait < canonicalCopy); + assert.match( + runCopiesSource, + /jobs: \[\.\.\.stateJobs, \.\.\.canonicalJobs, \.\.\.downstreamJobs\]/, + ); +}); + +test("rollback drill proves old and restored deployment data planes", () => { + const source = fs.readFileSync( + new URL("../staging-ci.js", import.meta.url), + "utf8", + ); + const drillSource = source.slice( + source.indexOf("const drillOwnedRollback"), + source.indexOf("const rollbackOwnedPromotion"), + ); + const recoverySource = source.slice( + source.indexOf("const rollbackOwnedPromotion"), + source.indexOf("const discardOwnedDeployment"), + ); + assert.ok( + (drillSource.match(/await switchLiveDeployment/g)?.length ?? 0) >= 2, + ); + assert.equal( + drillSource.match(/await queryDecisionEndpointSuite/g)?.length, + 2, + ); + assert.match(drillSource, /previousLiveDeploymentId/); + assert.match(drillSource, /assertDecisionEndpointSuiteReadable/); + assert.match(drillSource, /excludedRollbackEndpoints/); + assert.match(drillSource, /unavailableDecisionEndpoints/); + assert.match( + source, + /unavailableDecisionEndpoints[\s\S]*await decisionEndpointAvailable[\s\S]*filter\(\(\{ available \}\) => !available\)/, + ); + assert.match(drillSource, /assertSyntheticEndpointDecisions/); + assert.match(drillSource, /assertSyntheticBusinessDecisions/); + assert.match(drillSource, /readAndAssertPhaseHealth/); + assert.match(drillSource, /dataPlanePassed: true/); + assert.match(drillSource, /rollback_target_usable/); + assert.ok( + drillSource.indexOf("rollbackProbeError = error") < + drillSource.lastIndexOf("await switchLiveDeployment"), + ); + assert.match(recoverySource, /await queryDecisionEndpointSuite/); + assert.match(recoverySource, /assertDecisionEndpointSuiteReadable/); + assert.match( + recoverySource, + /The Tinybird rollback destination is not usable/, + ); + assert.ok( + recoverySource.indexOf("await queryDecisionEndpointSuite") < + recoverySource.indexOf("await deleteRetiredDeployment"), + ); +}); + +test("performance compares the retained deployment and a larger synthetic volume", () => { + const source = fs.readFileSync( + new URL("../staging-ci.js", import.meta.url), + "utf8", + ); + const verifySource = source.slice( + source.indexOf("const verify = async () =>"), + source.indexOf("const safeSyntheticIdentifier"), + ); + assert.match(verifySource, /options\.get\("baseline-deployment-id"\)/); + assert.match( + verifySource, + /deploymentId: baselineDeploymentId[\s\S]*deploymentId: state\.deploymentId[\s\S]*syntheticRunId: state\.loadRunId/, + ); + assert.match(verifySource, /representativeSamples/); + assert.match(verifySource, /representativeRows: state\.loadEventCount/); + assert.match(verifySource, /representativePerformancePassed/); + assert.match(verifySource, /assertRepresentativeEndpointCoverage/); + assert.match(verifySource, /representativeBudget/); + assert.match(verifySource, /absolute_only_no_independent_baseline/); + assert.match(verifySource, /round < 30/); + assert.match(verifySource, /excludedBaselineEndpoints/); + assert.match(verifySource, /unavailableDecisionEndpoints/); + assert.match( + verifySource, + /newEndpointsWithoutBaseline: excludedBaselineEndpoints/, + ); + assert.match(source, /LARGE_PERFORMANCE_EVENT_COUNT \?\? 100_000/); + assert.match(source, /PERFORMANCE_DAY_SPAN \?\? 30/); + assert.match(source, /LARGE_PERFORMANCE_DAY_SPAN \?\? 80/); + assert.match(source, /COLLECTOR_PERFORMANCE_REQUESTS \?\? 20/); + assert.match(source, /COLLECTOR_PERFORMANCE_CONCURRENCY \?\? 4/); + assert.match(source, /LARGE_PERFORMANCE_DIMENSION_BUCKETS \?\? 64/); + assert.match(source, /COPY_PIPELINE_WALL_CLOCK_BUDGET_MS \?\? 600_000/); + assert.match(source, /COPY_VISIBILITY_P95_BUDGET_MS \?\? 120_000/); + assert.match(source, /providerResourceMetrics/); +}); + +test("synthetic deletion targets the deployment used for ingestion", () => { + const source = fs.readFileSync( + new URL("../staging-ci.js", import.meta.url), + "utf8", + ); + const workflow = fs.readFileSync( + new URL("../../../.github/workflows/analytics.yml", import.meta.url), + "utf8", + ); + const deleteSource = source.slice( + source.indexOf("const deleteProductEventRows"), + source.indexOf("const eraseSyntheticIdentity"), + ); + const verifyCleanupSource = source.slice( + source.indexOf("const verifyCleanup"), + source.indexOf("const tokenScopeProbe"), + ); + assert.doesNotMatch(deleteSource, /__tb__min_deployment/); + assert.match(verifyCleanupSource, /syntheticRunId: state\.decisionRunId/); + assert.match( + verifyCleanupSource, + /Object\.values\(businessDecisionAssertions\)\.some/, + ); + assert.equal(deleteSource.match(/\.\.\.deploymentParameters/g)?.length, 2); + assert.match( + source, + /writeOutput\("discard", resolution\.pending \|\| resolution\.discard\)/, + ); + assert.match(workflow, /node scripts\/analytics\/staging-ci\.js cleanup/); + assert.match(source, /let target = await waitForOwnedMutationTarget/); + assert.match(source, /writeOutput\("target", target\)/); + assert.match( + source, + /target === "staging"[\s\S]*strategy: "deployment_discard"/, + ); + assert.match(source, /writeOutput\("requires_copies", "false"\)/); + assert.match(source, /writeOutput\("requires_copies", "true"\)/); + assert.match(source, /writeOutput\("requires_discard", "true"\)/); + assert.match(source, /writeOutput\("requires_discard", "false"\)/); + assert.match(source, /requestedTarget !== "live"/); + assert.doesNotMatch(source, /useDeploymentParameter/); + assert.match( + workflow, + /steps\.deployment-state\.outputs\.target \|\| \(steps\.tinybird\.outputs\.needs_promotion == 'true' && 'staging' \|\| 'live'\)/, + ); + assert.equal( + workflow.match(/--target "\$\{\{ steps\.cleanup\.outputs\.target \}\}"/g) + ?.length, + 1, + ); + assert.equal( + workflow.match( + /--target "\$\{\{ steps\.cleanup-copies\.outputs\.target \}\}"/g, + )?.length, + 1, + ); + assert.match(workflow, /analytics-staging-out-of-scope-/); + assert.match( + workflow, + /analytics-staging-37b8fef9-817f-4c3c-b21f-218c36a6077d/, + ); +}); diff --git a/scripts/analytics/tests/tooling.test.js b/scripts/analytics/tests/tooling.test.js new file mode 100644 index 00000000000..3e2df10c3a2 --- /dev/null +++ b/scripts/analytics/tests/tooling.test.js @@ -0,0 +1,624 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + assertSafeStep, + COMPOSE_FILE, + cloudEnvironment, + LOCAL_ENV_FILE, + localEnvironment, + localResourceToken, + operationPlan, + parseLocalStaticToken, + prepareLocalFixture, + redactProcessOutput, + TINYBIRD_PROJECT_DIR, + validateAnalyticsProject, + verifyCloudWorkspace, + withLocalDecisionTokenDatafiles, + writeLocalEnvironmentFile, +} from "../tooling.js"; + +test("analytics project passes deterministic static validation", () => { + assert.deepEqual(validateAnalyticsProject(), []); +}); + +test("all routine operation plans reject destructive commands", () => { + for (const operation of [ + "validate", + "test", + "compose-check", + "local", + "local-test", + "local-tokens", + "local-stop", + "cloud-check", + "cloud-deploy", + ]) { + for (const step of operationPlan(operation)) { + if (step.command) assert.doesNotThrow(() => assertSafeStep(step)); + } + } +}); + +test("cloud deploy checks before deploying and waits for completion", () => { + assert.ok( + operationPlan("cloud-deploy").some( + (step) => step.type === "verify-cloud-workspace", + ), + ); + const commands = operationPlan("cloud-deploy") + .filter((step) => step.command) + .map((step) => step.args.slice(-4).join(" ")); + assert.deepEqual(commands, [ + "tinybird-cloud-cli --cloud deploy --check", + "tinybird-cloud-cli --cloud deploy --wait", + ]); +}); + +test("local setup builds, verifies copied endpoints and writes its deterministic environment", () => { + const steps = operationPlan("local"); + const commands = steps + .filter((step) => step.command) + .map((step) => step.args.join(" ")); + assert.ok( + steps.some((step) => step.type === "build-local-with-decision-token"), + ); + assert.ok( + commands.some((command) => + command.endsWith( + "--local datasource append product_events_v1 --file fixtures/product_events_v1.local.ndjson", + ), + ), + ); + const appendIndex = steps.findIndex((step) => + step.args?.join(" ").includes("datasource append product_events_v1"), + ); + const resetIndex = steps.findIndex( + (step) => step.type === "reset-local-fixture", + ); + const pauseIndexes = steps + .map((step, index) => ({ index, command: step.args?.join(" ") ?? "" })) + .filter(({ command }) => command.includes("--local copy pause")) + .map(({ index }) => index); + assert.equal(pauseIndexes.length, 19); + assert.ok(pauseIndexes.every((index) => index < appendIndex)); + assert.ok(resetIndex > Math.max(...pauseIndexes)); + assert.ok(resetIndex < appendIndex); + const copyCommands = commands.filter((command) => + command.includes("--local copy run"), + ); + assert.equal(copyCommands.length, 16); + assert.ok( + copyCommands.every((command) => + command.includes( + "--param copy_max_threads=1 --param copy_run_id=run_local_copy_assertions", + ), + ), + ); + assert.equal( + copyCommands.filter((command) => command.includes("source_cutoff=")).length, + 4, + ); + assert.equal( + copyCommands.filter((command) => command.includes("generation_id=")).length, + 4, + ); + assert.equal( + copyCommands.filter((command) => command.includes("generation_kind=hot")) + .length, + 1, + ); + assert.ok(steps.some((step) => step.type === "verify-local")); + assert.ok(steps.some((step) => step.type === "write-local-env")); + assert.ok( + commands.some((command) => + command.includes("up -d --wait --wait-timeout 120 tinybird-local"), + ), + ); + const first = localEnvironment({}); + const second = localEnvironment({}); + assert.equal( + first.PRODUCT_ANALYTICS_TINYBIRD_TOKEN, + second.PRODUCT_ANALYTICS_TINYBIRD_TOKEN, + ); + assert.equal(first.PRODUCT_ANALYTICS_TINYBIRD_HOST, "http://127.0.0.1:7181"); + assert.equal( + localEnvironment({ TINYBIRD_LOCAL_PORT: "17181" }) + .PRODUCT_ANALYTICS_TINYBIRD_HOST, + "http://127.0.0.1:17181", + ); + assert.match( + first.PRODUCT_ANALYTICS_TINYBIRD_TOKEN, + /^p\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + const compose = fs.readFileSync(COMPOSE_FILE, "utf8"); + assert.match(compose, /working_dir: \/workspace\/tinybird/); + assert.match( + compose, + /tinybird:\/workspace\/tinybird:ro[\s\S]*tinybird-local-workspace\.json:\/workspace\/\.tinyb:ro/, + ); +}); + +test("local fixtures use bounded current dates", () => { + const { dates, rows } = prepareLocalFixture( + new Date("2026-07-31T12:00:00.000Z"), + ); + assert.deepEqual(dates, { + "2099-01-10": "2026-07-29", + "2099-01-11": "2026-07-30", + "2099-01-12": "2026-07-31", + }); + const fixture = fs.readFileSync( + path.join( + TINYBIRD_PROJECT_DIR, + "fixtures", + "product_events_v1.local.ndjson", + ), + "utf8", + ); + assert.doesNotMatch(fixture, /2099-01-/); + assert.match(fixture, /2026-07-31/); + assert.ok(rows.every((row) => row.event_id.endsWith("_20260731"))); + assert.ok( + rows.every( + (row) => !row.anonymous_id || row.anonymous_id.endsWith("_20260731"), + ), + ); + assert.ok( + rows.every( + (row) => !row.session_id || row.session_id.endsWith("_20260731"), + ), + ); + assert.ok( + rows.every((row) => !row.user_id || row.user_id.endsWith("_20260731")), + ); + assert.ok( + rows.every( + (row) => + !row.organization_id || row.organization_id.endsWith("_20260731"), + ), + ); + assert.ok(rows.every((row) => /^[0-9a-f]{32}$/.test(row.payload_hash))); + const engagement = rows.find((row) => row.event_name === "page_engagement"); + assert.match(JSON.parse(engagement.properties).page_view_id, /_20260731$/); +}); + +test("local resource discovery returns only the named scoped token", async () => { + const token = await localResourceToken( + localEnvironment({}), + "product_events_agent_read", + async () => + new Response( + JSON.stringify({ + tokens: [ + { name: "workspace", token: "p.workspace-token-value" }, + { + name: "product_events_agent_read", + token: "p.resource-token-value", + }, + ], + }), + { status: 200 }, + ), + ); + assert.equal(token, "p.resource-token-value"); +}); + +test("local decision verification restores its temporary token datafiles", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "analytics-token-")); + const pipesDir = path.join(tempDir, "pipes"); + fs.mkdirSync(pipesDir); + const pipeFile = path.join(pipesDir, "product_events_daily.pipe"); + const original = "NODE endpoint\nSQL >\n\tSELECT 1\n"; + fs.writeFileSync(pipeFile, original); + + await assert.rejects( + withLocalDecisionTokenDatafiles(tempDir, async () => { + assert.equal( + fs.readFileSync(pipeFile, "utf8"), + `TOKEN product_events_local_verification READ\n${original}`, + ); + throw new Error("synthetic build failure"); + }, ["product_events_daily"]), + /synthetic build failure/, + ); + assert.equal(fs.readFileSync(pipeFile, "utf8"), original); +}); + +test("local resource discovery falls back to the local user token", async () => { + const authorizations = []; + const token = await localResourceToken( + localEnvironment({}), + "product_events_ingest", + async (_url, options) => { + authorizations.push(options.headers.Authorization); + if (authorizations.length === 1) + return new Response(null, { status: 403 }); + return new Response( + JSON.stringify({ + tokens: [ + { + name: "product_events_ingest", + token: "p.resource-token-value", + }, + ], + }), + { status: 200 }, + ); + }, + ); + assert.equal(token, "p.resource-token-value"); + assert.equal(authorizations.length, 2); +}); + +test("local resource discovery uses the CLI when the token API is unsupported", async () => { + const environment = localEnvironment({}); + const token = await localResourceToken( + environment, + "product_events_agent_read", + async () => new Response(null, { status: 403 }), + async (_environment, tokenName) => `p.${tokenName}.scoped`, + ); + assert.equal(token, "p.product_events_agent_read.scoped"); +}); + +test("local CLI token parsing returns only the exact named token", () => { + const output = ` +-------------------- +name: product_events_ingest +token: p.ingest.secret +-------------------- +name: product_events_agent_read +token: p.read.secret +-------------------- +`; + assert.equal( + parseLocalStaticToken(output, "product_events_agent_read"), + "p.read.secret", + ); + assert.equal(parseLocalStaticToken(output, "missing"), undefined); +}); + +test("child process output redacts static and query-string tokens", () => { + assert.equal( + redactProcessOutput( + "token=p.payload.signature Authorization: p.another.signature", + ), + "token=[REDACTED] Authorization: [REDACTED]", + ); +}); + +test("unsafe analytics operations are blocked", () => { + assert.throws( + () => + assertSafeStep({ + command: "tb", + args: ["deploy", "--allow-destructive-operations"], + }), + /Refusing destructive analytics command/, + ); + assert.throws( + () => assertSafeStep({ command: "tb", args: ["workspace", "clear"] }), + /Refusing destructive analytics command/, + ); +}); + +test("cloud auth requires a dedicated deploy token", () => { + assert.throws(() => cloudEnvironment({}), /TINYBIRD_DEPLOY_TOKEN/); + assert.throws( + () => cloudEnvironment({ TINYBIRD_DEPLOY_TOKEN: "deploy-token" }), + /TINYBIRD_WORKSPACE_ID/, + ); + const environment = cloudEnvironment({ + TINYBIRD_DEPLOY_TOKEN: "deploy-token", + PRODUCT_ANALYTICS_TINYBIRD_HOST: "https://example.tinybird.co", + TINYBIRD_WORKSPACE_ID: "37b8fef9-817f-4c3c-b21f-218c36a6077d", + }); + assert.equal(environment.TINYBIRD_TOKEN, "deploy-token"); + assert.equal(environment.TB_TOKEN, "deploy-token"); + assert.equal(environment.TINYBIRD_URL, "https://example.tinybird.co"); + assert.equal(environment.TB_HOST, "https://example.tinybird.co"); + assert.equal( + environment.TINYBIRD_WORKSPACE_ID, + "37b8fef9-817f-4c3c-b21f-218c36a6077d", + ); + assert.throws( + () => + cloudEnvironment({ + TINYBIRD_DEPLOY_TOKEN: "deploy-token", + TINYBIRD_WORKSPACE_ID: "12345678-1234-4234-8234-123456789abc", + }), + /only to the staging workspace/, + ); +}); + +test("cloud deploy verifies the token workspace before mutation", () => { + const workspaceId = "37b8fef9-817f-4c3c-b21f-218c36a6077d"; + const token = `p.${Buffer.from( + JSON.stringify({ + u: workspaceId, + id: "87654321-4321-4321-8321-cba987654321", + host: null, + }), + ).toString("base64url")}.signature`; + const env = { + TINYBIRD_DEPLOY_TOKEN: token, + TINYBIRD_URL: "https://api.tinybird.co", + TINYBIRD_WORKSPACE_ID: workspaceId, + }; + assert.equal(verifyCloudWorkspace(env), workspaceId); + const wrongWorkspaceToken = `p.${Buffer.from( + JSON.stringify({ + u: "87654321-4321-4321-8321-cba987654321", + id: "87654321-4321-4321-8321-cba987654321", + host: null, + }), + ).toString("base64url")}.signature`; + assert.throws( + () => + verifyCloudWorkspace({ + ...env, + TINYBIRD_DEPLOY_TOKEN: wrongWorkspaceToken, + }), + /does not target/, + ); + assert.throws( + () => + verifyCloudWorkspace({ + ...env, + TINYBIRD_DEPLOY_TOKEN: "invalid-token", + }), + /Unable to parse Tinybird deploy token identity/, + ); +}); + +test("local credentials are written to a private gitignored env file", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cap-analytics-env-")); + const filePath = path.join(tempRoot, ".env.analytics.local"); + try { + writeLocalEnvironmentFile( + filePath, + localEnvironment({}), + "p.scoped-runtime-token", + ); + const contents = fs.readFileSync(filePath, "utf8"); + assert.match(contents, /^PRODUCT_ANALYTICS_TINYBIRD_HOST=/m); + assert.match(contents, /^PRODUCT_ANALYTICS_TINYBIRD_TOKEN=/m); + if (process.platform !== "win32") { + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600); + } + assert.equal(path.basename(LOCAL_ENV_FILE), ".env.analytics.local"); + } finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + } +}); + +test("fixture validation allows identical duplicate deliveries", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cap-analytics-")); + const projectDir = path.join(tempRoot, "tinybird"); + try { + fs.cpSync(TINYBIRD_PROJECT_DIR, projectDir, { recursive: true }); + const fixturePath = path.join( + projectDir, + "fixtures", + "product_events_v1.ndjson", + ); + const firstRow = fs.readFileSync(fixturePath, "utf8").split(/\r?\n/)[0]; + fs.appendFileSync(fixturePath, `${firstRow}\n`); + assert.deepEqual(validateAnalyticsProject(projectDir), []); + } finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + } +}); + +test("fixture validation rejects the same event ID with a different hash", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cap-analytics-")); + const projectDir = path.join(tempRoot, "tinybird"); + try { + fs.cpSync(TINYBIRD_PROJECT_DIR, projectDir, { recursive: true }); + const fixturePath = path.join( + projectDir, + "fixtures", + "product_events_v1.ndjson", + ); + const fixtureContents = fs.readFileSync(fixturePath, "utf8"); + const firstRow = JSON.parse(fixtureContents.split(/\r?\n/)[0]); + const conflictingRow = { + ...firstRow, + payload_hash: "ffffffffffffffffffffffffffffffff", + }; + fs.writeFileSync( + fixturePath, + `${fixtureContents.trimEnd()}\n${JSON.stringify(conflictingRow)}\n`, + ); + assert.ok( + validateAnalyticsProject(projectDir).some((issue) => + issue.includes("has conflicting payload hashes"), + ), + ); + } finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + } +}); + +test("project validation rejects agent access to raw product events", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cap-analytics-")); + const projectDir = path.join(tempRoot, "tinybird"); + try { + fs.cpSync(TINYBIRD_PROJECT_DIR, projectDir, { recursive: true }); + const datasourcePath = path.join( + projectDir, + "datasources", + "product_events_v1.datasource", + ); + const contents = fs.readFileSync(datasourcePath, "utf8"); + fs.writeFileSync( + datasourcePath, + contents.replace( + "TOKEN product_events_ingest APPEND", + "TOKEN product_events_ingest APPEND\nTOKEN product_events_agent_read READ", + ), + ); + assert.ok( + validateAnalyticsProject(projectDir).some((issue) => + issue.includes("must not expose raw identity data to agents"), + ), + ); + } finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + } +}); + +test("project validation rejects the agent token on Copy Pipes", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cap-analytics-")); + const projectDir = path.join(tempRoot, "tinybird"); + try { + fs.cpSync(TINYBIRD_PROJECT_DIR, projectDir, { recursive: true }); + const pipePath = path.join( + projectDir, + "pipes", + "snapshot_product_events_canonical_v1.pipe", + ); + const contents = fs.readFileSync(pipePath, "utf8"); + fs.writeFileSync( + pipePath, + contents.replace( + "TOKEN product_events_copy_runner READ", + "TOKEN product_events_agent_read READ", + ), + ); + const issues = validateAnalyticsProject(projectDir); + assert.ok( + issues.some((issue) => + issue.includes("missing its execution-only Copy token"), + ), + ); + assert.ok( + issues.some((issue) => + issue.includes("must not grant Copy execution to the agent token"), + ), + ); + } finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + } +}); + +test("project validation rejects the Copy runner token on decision endpoints", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cap-analytics-")); + const projectDir = path.join(tempRoot, "tinybird"); + try { + fs.cpSync(TINYBIRD_PROJECT_DIR, projectDir, { recursive: true }); + const pipePath = path.join( + projectDir, + "pipes", + "product_events_health.pipe", + ); + const contents = fs.readFileSync(pipePath, "utf8"); + fs.writeFileSync( + pipePath, + contents.replace( + "\n\nNODE product_events_health_node", + "\nTOKEN product_events_copy_runner READ\n\nNODE product_events_health_node", + ), + ); + const issues = validateAnalyticsProject(projectDir); + assert.ok( + issues.some((issue) => + issue.includes( + "must use an expiring resource-scoped JWT instead of static token grants", + ), + ), + ); + assert.ok( + issues.some((issue) => + issue.includes("must not be queryable by the Copy runner token"), + ), + ); + } finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + } +}); + +test("project validation rejects extra Copy and erasure lookup grants", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cap-analytics-")); + const projectDir = path.join(tempRoot, "tinybird"); + try { + fs.cpSync(TINYBIRD_PROJECT_DIR, projectDir, { recursive: true }); + const datasourcePath = path.join( + projectDir, + "datasources", + "product_events_v1.datasource", + ); + const contents = fs.readFileSync(datasourcePath, "utf8"); + fs.writeFileSync( + datasourcePath, + contents.replace( + "TOKEN product_events_ingest APPEND", + "TOKEN product_events_ingest APPEND\nTOKEN product_events_copy_runner READ", + ), + ); + const endpointPath = path.join( + projectDir, + "pipes", + "product_events_health.pipe", + ); + const endpoint = fs.readFileSync(endpointPath, "utf8"); + fs.writeFileSync( + endpointPath, + endpoint.replace( + "\n\nNODE product_events_health_node", + "\nTOKEN product_events_erasure_lookup READ\n\nNODE product_events_health_node", + ), + ); + const issues = validateAnalyticsProject(projectDir); + assert.ok( + issues.some((issue) => + issue.includes( + "product_events_copy_runner has unexpected datasource:product_events_v1:READ", + ), + ), + ); + assert.ok( + issues.some((issue) => + issue.includes( + "product_events_erasure_lookup has unexpected pipe:product_events_health:READ", + ), + ), + ); + } finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + } +}); + +test("project validation rejects duplicate Tinybird resource names", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cap-analytics-")); + const projectDir = path.join(tempRoot, "tinybird"); + try { + fs.cpSync(TINYBIRD_PROJECT_DIR, projectDir, { recursive: true }); + fs.copyFileSync( + path.join( + projectDir, + "pipes", + "snapshot_product_events_daily_exact.pipe", + ), + path.join(projectDir, "pipes", "product_events_daily_exact.pipe"), + ); + assert.ok( + validateAnalyticsProject(projectDir).some((issue) => + issue.includes( + "resource name product_events_daily_exact is not unique", + ), + ), + ); + } finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + } +}); + +test("unknown analytics commands fail before executing anything", () => { + assert.throws(() => operationPlan("unknown"), /Unknown analytics command/); +}); diff --git a/scripts/analytics/tinybird-local-workspace.json b/scripts/analytics/tinybird-local-workspace.json new file mode 100644 index 00000000000..8db54957b9b --- /dev/null +++ b/scripts/analytics/tinybird-local-workspace.json @@ -0,0 +1,4 @@ +{ + "id": "00000000-0000-4000-8000-000000000001", + "name": "cap-local" +} diff --git a/scripts/analytics/tinybird-mcp.example.json b/scripts/analytics/tinybird-mcp.example.json new file mode 100644 index 00000000000..06b89f6c3a2 --- /dev/null +++ b/scripts/analytics/tinybird-mcp.example.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "cap-product-analytics": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://mcp.tinybird.co?token=TINYBIRD_AGENT_TOKEN&host=TINYBIRD_URL" + ] + } + } +} diff --git a/scripts/analytics/tinybird/datasources/product_activation_daily_exact.datasource b/scripts/analytics/tinybird/datasources/product_activation_daily_exact.datasource new file mode 100644 index 00000000000..98d5a0421df --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_activation_daily_exact.datasource @@ -0,0 +1,19 @@ +DESCRIPTION > + Eight-hundred-day signup cohorts and the defensible activation milestone of creating a first share link within seven UTC days. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + cohort_date Date, + signups AggregateFunction(uniqExact, String), + activated_creators AggregateFunction(uniqExact, String), + time_to_activation_ms UInt64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(cohort_date) +ENGINE_SORTING_KEY (copy_run_id, synthetic_run_id, cohort_date) +ENGINE_TTL cohort_date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_analytics_generations_v2.datasource b/scripts/analytics/tinybird/datasources/product_analytics_generations_v2.datasource new file mode 100644 index 00000000000..75ae450b29c --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_analytics_generations_v2.datasource @@ -0,0 +1,18 @@ +DESCRIPTION > + Publication barrier for complete cross-model bounded analytics generations. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + generation_id String, + source_cutoff DateTime64(3), + generation_kind LowCardinality(String), + settled_date Date, + published_at DateTime64(3), + copy_run_id String + +ENGINE MergeTree +ENGINE_PARTITION_KEY toYYYYMM(source_cutoff) +ENGINE_SORTING_KEY (generation_kind, settled_date, source_cutoff, generation_id) +ENGINE_TTL toDateTime(source_cutoff) + INTERVAL 807 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_attribution_daily_exact.datasource b/scripts/analytics/tinybird/datasources/product_attribution_daily_exact.datasource new file mode 100644 index 00000000000..5df95e5b93f --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_attribution_daily_exact.datasource @@ -0,0 +1,27 @@ +DESCRIPTION > + Eight-hundred-day privacy-safe campaign attribution with exact visitor states for first, session, and last touch. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + date Date, + attribution_model LowCardinality(String), + platform LowCardinality(String), + app_version LowCardinality(String), + hostname LowCardinality(String), + country LowCardinality(String), + attribution_source LowCardinality(String), + attribution_medium LowCardinality(String), + campaign LowCardinality(String), + visitors AggregateFunction(uniqExact, String), + visits UInt64, + pageviews UInt64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY (copy_run_id, synthetic_run_id, date, attribution_model, platform, app_version, hostname, country, attribution_source, attribution_medium, campaign) +ENGINE_TTL date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_creator_retention_exact.datasource b/scripts/analytics/tinybird/datasources/product_creator_retention_exact.datasource new file mode 100644 index 00000000000..c4ce9f96279 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_creator_retention_exact.datasource @@ -0,0 +1,20 @@ +DESCRIPTION > + Eight-hundred-day creator and organization activity cohorts using successful creation-value events. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + cohort_date Date, + activity_date Date, + platform LowCardinality(String), + creator_users AggregateFunction(uniqExact, String), + creator_organizations AggregateFunction(uniqExact, String) + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(cohort_date) +ENGINE_SORTING_KEY (copy_run_id, synthetic_run_id, cohort_date, activity_date, platform) +ENGINE_TTL cohort_date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_event_day_states_v2.datasource b/scripts/analytics/tinybird/datasources/product_event_day_states_v2.datasource new file mode 100644 index 00000000000..7525d6ce8d9 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_event_day_states_v2.datasource @@ -0,0 +1,41 @@ +DESCRIPTION > + Time-partitioned per-event aggregate state for bounded exact decision refreshes. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + occurred_date Date, + event_id String, + payload_hashes AggregateFunction(uniqExact, FixedString(32)), + payload_hash AggregateFunction(argMin, FixedString(32), DateTime64(3)), + occurred_at AggregateFunction(argMin, DateTime64(3), DateTime64(3)), + first_received_at SimpleAggregateFunction(min, DateTime64(3)), + received_at SimpleAggregateFunction(max, DateTime64(3)), + event_name AggregateFunction(argMin, String, DateTime64(3)), + schema_version AggregateFunction(argMin, UInt16, DateTime64(3)), + source AggregateFunction(argMin, String, DateTime64(3)), + platform AggregateFunction(argMin, String, DateTime64(3)), + anonymous_id AggregateFunction(argMin, String, DateTime64(3)), + session_id AggregateFunction(argMin, String, DateTime64(3)), + user_id AggregateFunction(argMin, String, DateTime64(3)), + organization_id AggregateFunction(argMin, String, DateTime64(3)), + app_version AggregateFunction(argMin, String, DateTime64(3)), + pathname AggregateFunction(argMin, String, DateTime64(3)), + referrer AggregateFunction(argMin, String, DateTime64(3)), + country AggregateFunction(argMin, String, DateTime64(3)), + region AggregateFunction(argMin, String, DateTime64(3)), + city AggregateFunction(argMin, String, DateTime64(3)), + hostname AggregateFunction(argMin, String, DateTime64(3)), + browser AggregateFunction(argMin, String, DateTime64(3)), + device AggregateFunction(argMin, String, DateTime64(3)), + os AggregateFunction(argMin, String, DateTime64(3)), + channel AggregateFunction(argMin, String, DateTime64(3)), + traffic_class AggregateFunction(argMin, String, DateTime64(3)), + synthetic_run_id AggregateFunction(argMin, String, DateTime64(3)), + properties AggregateFunction(argMin, String, DateTime64(3)) + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(occurred_date) +ENGINE_SORTING_KEY (occurred_date, event_id) +ENGINE_TTL occurred_date + INTERVAL 807 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_event_id_states_v2.datasource b/scripts/analytics/tinybird/datasources/product_event_id_states_v2.datasource new file mode 100644 index 00000000000..e878ba8ed4c --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_event_id_states_v2.datasource @@ -0,0 +1,40 @@ +DESCRIPTION > + Streaming per-event aggregate state used to deduplicate delivery retries before decision aggregation. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + event_id String, + payload_hashes AggregateFunction(uniqExact, FixedString(32)), + payload_hash AggregateFunction(argMin, FixedString(32), DateTime64(3)), + occurred_at AggregateFunction(argMin, DateTime64(3), DateTime64(3)), + first_received_at SimpleAggregateFunction(min, DateTime64(3)), + received_at SimpleAggregateFunction(max, DateTime64(3)), + event_name AggregateFunction(argMin, String, DateTime64(3)), + schema_version AggregateFunction(argMin, UInt16, DateTime64(3)), + source AggregateFunction(argMin, String, DateTime64(3)), + platform AggregateFunction(argMin, String, DateTime64(3)), + anonymous_id AggregateFunction(argMin, String, DateTime64(3)), + session_id AggregateFunction(argMin, String, DateTime64(3)), + user_id AggregateFunction(argMin, String, DateTime64(3)), + organization_id AggregateFunction(argMin, String, DateTime64(3)), + app_version AggregateFunction(argMin, String, DateTime64(3)), + pathname AggregateFunction(argMin, String, DateTime64(3)), + referrer AggregateFunction(argMin, String, DateTime64(3)), + country AggregateFunction(argMin, String, DateTime64(3)), + region AggregateFunction(argMin, String, DateTime64(3)), + city AggregateFunction(argMin, String, DateTime64(3)), + hostname AggregateFunction(argMin, String, DateTime64(3)), + browser AggregateFunction(argMin, String, DateTime64(3)), + device AggregateFunction(argMin, String, DateTime64(3)), + os AggregateFunction(argMin, String, DateTime64(3)), + channel AggregateFunction(argMin, String, DateTime64(3)), + traffic_class AggregateFunction(argMin, String, DateTime64(3)), + synthetic_run_id AggregateFunction(argMin, String, DateTime64(3)), + properties AggregateFunction(argMin, String, DateTime64(3)) + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY cityHash64(event_id) % 64 +ENGINE_SORTING_KEY event_id +ENGINE_TTL toDateTime(received_at) + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_events_canonical_v1.datasource b/scripts/analytics/tinybird/datasources/product_events_canonical_v1.datasource new file mode 100644 index 00000000000..8f975667910 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_events_canonical_v1.datasource @@ -0,0 +1,39 @@ +DESCRIPTION > + Canonical non-conflicting product events rebuilt from append-only delivery attempts. + +TOKEN product_events_copy_runner APPEND +TOKEN product_events_erasure_lookup READ + +SCHEMA > + event_id String, + payload_hash FixedString(32), + occurred_at DateTime64(3), + received_at DateTime64(3), + event_name LowCardinality(String), + schema_version UInt16, + source LowCardinality(String), + platform LowCardinality(String), + anonymous_id String, + session_id String, + user_id String, + organization_id String, + app_version LowCardinality(String), + pathname String, + referrer String, + country LowCardinality(String), + region LowCardinality(String), + city LowCardinality(String), + hostname LowCardinality(String), + browser LowCardinality(String), + device LowCardinality(String), + os LowCardinality(String), + channel LowCardinality(String), + traffic_class LowCardinality(String), + synthetic_run_id String, + properties String + +ENGINE MergeTree +ENGINE_PARTITION_KEY toYYYYMM(received_at) +ENGINE_SORTING_KEY (occurred_at, event_name, event_id) +ENGINE_TTL toDateTime(received_at) + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 8192 diff --git a/scripts/analytics/tinybird/datasources/product_events_daily_cold_v2.datasource b/scripts/analytics/tinybird/datasources/product_events_daily_cold_v2.datasource new file mode 100644 index 00000000000..c64f9ec6893 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_events_daily_cold_v2.datasource @@ -0,0 +1,63 @@ +DESCRIPTION > + Settled long-lived event-decision partitions with one active generation per UTC date. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + source_cutoff DateTime64(3), + generation_id String, + is_marker UInt8, + calculated_at DateTime64(3), + copy_run_id String, + date Date, + event_name LowCardinality(String), + schema_version UInt16, + source LowCardinality(String), + platform LowCardinality(String), + app_version LowCardinality(String), + hostname LowCardinality(String), + country LowCardinality(String), + device LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + channel LowCardinality(String), + traffic_class LowCardinality(String), + synthetic_run_id String, + plan_id LowCardinality(String), + recording_status LowCardinality(String), + payment_status LowCardinality(String), + subscription_status LowCardinality(String), + currency LowCardinality(String), + billing_interval LowCardinality(String), + change_kind LowCardinality(String), + previous_status LowCardinality(String), + new_status LowCardinality(String), + previous_plan_id LowCardinality(String), + quantity Int64, + previous_quantity Int64, + new_quantity Int64, + seat_delta Int64, + first_purchase LowCardinality(String), + guest_checkout LowCardinality(String), + onboarding LowCardinality(String), + cancel_at_period_end LowCardinality(String), + fully_refunded LowCardinality(String), + ended_at Int64, + trial_end_at Int64, + amount_due_minor Int64, + attempt_count Int64, + experiment_id LowCardinality(String), + experiment_variant LowCardinality(String), + assignment_version LowCardinality(String), + delivery_loss_count UInt64, + events UInt64, + actors AggregateFunction(uniqExact, String), + users AggregateFunction(uniqExact, String), + organizations AggregateFunction(uniqExact, String), + revenue_minor Int64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY (generation_id, is_marker, date, event_name, schema_version, source, platform, app_version, hostname, country, device, browser, os, channel, traffic_class, synthetic_run_id, plan_id, recording_status, payment_status, subscription_status, currency, billing_interval, change_kind, previous_status, new_status, previous_plan_id, quantity, previous_quantity, new_quantity, seat_delta, first_purchase, guest_checkout, onboarding, cancel_at_period_end, fully_refunded, ended_at, trial_end_at, amount_due_minor, attempt_count, experiment_id, experiment_variant, assignment_version) +ENGINE_TTL date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_events_daily_exact.datasource b/scripts/analytics/tinybird/datasources/product_events_daily_exact.datasource new file mode 100644 index 00000000000..d8e2b6fbd01 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_events_daily_exact.datasource @@ -0,0 +1,60 @@ +DESCRIPTION > + Eight-hundred-day exact daily product decision metrics rebuilt from canonical event IDs. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + calculated_at DateTime64(3), + copy_run_id String, + date Date, + event_name LowCardinality(String), + schema_version UInt16, + source LowCardinality(String), + platform LowCardinality(String), + app_version LowCardinality(String), + hostname LowCardinality(String), + country LowCardinality(String), + device LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + channel LowCardinality(String), + traffic_class LowCardinality(String), + synthetic_run_id String, + plan_id LowCardinality(String), + recording_status LowCardinality(String), + payment_status LowCardinality(String), + subscription_status LowCardinality(String), + currency LowCardinality(String), + billing_interval LowCardinality(String), + change_kind LowCardinality(String), + previous_status LowCardinality(String), + new_status LowCardinality(String), + previous_plan_id LowCardinality(String), + quantity Int64, + previous_quantity Int64, + new_quantity Int64, + seat_delta Int64, + first_purchase LowCardinality(String), + guest_checkout LowCardinality(String), + onboarding LowCardinality(String), + cancel_at_period_end LowCardinality(String), + fully_refunded LowCardinality(String), + ended_at Int64, + trial_end_at Int64, + amount_due_minor Int64, + attempt_count Int64, + experiment_id LowCardinality(String), + experiment_variant LowCardinality(String), + assignment_version LowCardinality(String), + delivery_loss_count UInt64, + events UInt64, + actors AggregateFunction(uniqExact, String), + users AggregateFunction(uniqExact, String), + organizations AggregateFunction(uniqExact, String), + revenue_minor Int64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY (copy_run_id, date, event_name, schema_version, source, platform, app_version, hostname, country, device, browser, os, channel, traffic_class, synthetic_run_id, plan_id, recording_status, payment_status, subscription_status, currency, billing_interval, change_kind, previous_status, new_status, previous_plan_id, quantity, previous_quantity, new_quantity, seat_delta, first_purchase, guest_checkout, onboarding, cancel_at_period_end, fully_refunded, ended_at, trial_end_at, amount_due_minor, attempt_count, experiment_id, experiment_variant, assignment_version) +ENGINE_TTL date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_events_daily_hot_v2.datasource b/scripts/analytics/tinybird/datasources/product_events_daily_hot_v2.datasource new file mode 100644 index 00000000000..5c23120dd4b --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_events_daily_hot_v2.datasource @@ -0,0 +1,63 @@ +DESCRIPTION > + Bounded mutable event-decision window rebuilt from exact canonical event states. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + source_cutoff DateTime64(3), + generation_id String, + is_marker UInt8, + calculated_at DateTime64(3), + copy_run_id String, + date Date, + event_name LowCardinality(String), + schema_version UInt16, + source LowCardinality(String), + platform LowCardinality(String), + app_version LowCardinality(String), + hostname LowCardinality(String), + country LowCardinality(String), + device LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + channel LowCardinality(String), + traffic_class LowCardinality(String), + synthetic_run_id String, + plan_id LowCardinality(String), + recording_status LowCardinality(String), + payment_status LowCardinality(String), + subscription_status LowCardinality(String), + currency LowCardinality(String), + billing_interval LowCardinality(String), + change_kind LowCardinality(String), + previous_status LowCardinality(String), + new_status LowCardinality(String), + previous_plan_id LowCardinality(String), + quantity Int64, + previous_quantity Int64, + new_quantity Int64, + seat_delta Int64, + first_purchase LowCardinality(String), + guest_checkout LowCardinality(String), + onboarding LowCardinality(String), + cancel_at_period_end LowCardinality(String), + fully_refunded LowCardinality(String), + ended_at Int64, + trial_end_at Int64, + amount_due_minor Int64, + attempt_count Int64, + experiment_id LowCardinality(String), + experiment_variant LowCardinality(String), + assignment_version LowCardinality(String), + delivery_loss_count UInt64, + events UInt64, + actors AggregateFunction(uniqExact, String), + users AggregateFunction(uniqExact, String), + organizations AggregateFunction(uniqExact, String), + revenue_minor Int64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY (generation_id, is_marker, date, event_name, schema_version, source, platform, app_version, hostname, country, device, browser, os, channel, traffic_class, synthetic_run_id, plan_id, recording_status, payment_status, subscription_status, currency, billing_interval, change_kind, previous_status, new_status, previous_plan_id, quantity, previous_quantity, new_quantity, seat_delta, first_purchase, guest_checkout, onboarding, cancel_at_period_end, fully_refunded, ended_at, trial_end_at, amount_due_minor, attempt_count, experiment_id, experiment_variant, assignment_version) +ENGINE_TTL date + INTERVAL 16 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_events_health_hourly_exact.datasource b/scripts/analytics/tinybird/datasources/product_events_health_hourly_exact.datasource new file mode 100644 index 00000000000..161018ff4b7 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_events_health_hourly_exact.datasource @@ -0,0 +1,24 @@ +DESCRIPTION > + Hourly delivery aggregates for stable, bounded product analytics health checks. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + hour DateTime, + calculated_at DateTime64(3), + copy_run_id String, + platform LowCardinality(String), + app_version LowCardinality(String), + received_rows SimpleAggregateFunction(sum, UInt64), + unique_events AggregateFunction(uniqExact, String), + unique_payloads AggregateFunction(uniqExact, Tuple(String, FixedString(32))), + future_events SimpleAggregateFunction(sum, UInt64), + late_events SimpleAggregateFunction(sum, UInt64), + missing_identity_events SimpleAggregateFunction(sum, UInt64), + ingestion_lag_ms AggregateFunction(quantilesTDigest(0.5, 0.95, 0.99), Float64) + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(hour) +ENGINE_SORTING_KEY (copy_run_id, hour, platform, app_version) +ENGINE_TTL hour + INTERVAL 90 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_events_v1.datasource b/scripts/analytics/tinybird/datasources/product_events_v1.datasource new file mode 100644 index 00000000000..4d4e58c3b04 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_events_v1.datasource @@ -0,0 +1,38 @@ +DESCRIPTION > + Provider-neutral product analytics events emitted by Cap. + +TOKEN product_events_ingest APPEND +TOKEN product_events_erasure_lookup READ +SCHEMA > + event_id String `json:$.event_id`, + payload_hash FixedString(32) `json:$.payload_hash`, + occurred_at DateTime64(3) `json:$.occurred_at`, + received_at DateTime64(3) `json:$.received_at`, + event_name LowCardinality(String) `json:$.event_name`, + schema_version UInt16 `json:$.schema_version`, + source LowCardinality(String) `json:$.source`, + platform LowCardinality(String) `json:$.platform`, + anonymous_id String `json:$.anonymous_id` DEFAULT '', + session_id String `json:$.session_id` DEFAULT '', + user_id String `json:$.user_id` DEFAULT '', + organization_id String `json:$.organization_id` DEFAULT '', + app_version LowCardinality(String) `json:$.app_version` DEFAULT '', + pathname String `json:$.pathname` DEFAULT '', + referrer String `json:$.referrer` DEFAULT '', + country LowCardinality(String) `json:$.country` DEFAULT '', + region LowCardinality(String) `json:$.region` DEFAULT '', + city LowCardinality(String) `json:$.city` DEFAULT '', + hostname LowCardinality(String) `json:$.hostname` DEFAULT '', + browser LowCardinality(String) `json:$.browser` DEFAULT '', + device LowCardinality(String) `json:$.device` DEFAULT '', + os LowCardinality(String) `json:$.os` DEFAULT '', + channel LowCardinality(String) `json:$.channel` DEFAULT '', + traffic_class LowCardinality(String) `json:$.traffic_class` DEFAULT 'external', + synthetic_run_id String `json:$.synthetic_run_id` DEFAULT '', + properties String `json:$.properties` DEFAULT '{}' + +ENGINE MergeTree +ENGINE_PARTITION_KEY toYYYYMM(received_at) +ENGINE_SORTING_KEY (received_at, event_id) +ENGINE_TTL toDateTime(received_at) + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 8192 diff --git a/scripts/analytics/tinybird/datasources/product_experiment_outcomes_exact.datasource b/scripts/analytics/tinybird/datasources/product_experiment_outcomes_exact.datasource new file mode 100644 index 00000000000..50ddbff6f27 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_experiment_outcomes_exact.datasource @@ -0,0 +1,25 @@ +DESCRIPTION > + Eight-hundred-day aggregate experiment cohorts anchored only to explicit exposures and first authoritative outcomes. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + cohort_date Date, + experiment_id LowCardinality(String), + assignment_version LowCardinality(String), + variant LowCardinality(String), + platform LowCardinality(String), + app_version LowCardinality(String), + outcome_name LowCardinality(String), + exposed_actors UInt64, + converted_actors UInt64, + copy_marker UInt8 + +ENGINE SummingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(cohort_date) +ENGINE_SORTING_KEY (copy_run_id, synthetic_run_id, cohort_date, experiment_id, assignment_version, variant, platform, app_version, outcome_name) +ENGINE_TTL cohort_date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_identity_funnel_exact.datasource b/scripts/analytics/tinybird/datasources/product_identity_funnel_exact.datasource new file mode 100644 index 00000000000..ece805e2398 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_identity_funnel_exact.datasource @@ -0,0 +1,34 @@ +DESCRIPTION > + Privacy-safe anonymous acquisition to authenticated conversion cohorts rebuilt from canonical identity links. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + cohort_date Date, + channel LowCardinality(String), + attribution_source LowCardinality(String), + attribution_medium LowCardinality(String), + campaign LowCardinality(String), + country LowCardinality(String), + linked_visitors UInt64, + linked_users UInt64, + signup_users UInt64, + organizations UInt64, + guest_checkout_visitors UInt64, + guest_purchasers UInt64, + authenticated_checkout_users UInt64, + web_checkout_users UInt64, + desktop_checkout_users UInt64, + mobile_checkout_users UInt64, + cross_device_checkout_users UInt64, + trial_users UInt64, + purchasers UInt64 + +ENGINE SummingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(cohort_date) +ENGINE_SORTING_KEY (copy_run_id, synthetic_run_id, cohort_date, channel, attribution_source, attribution_medium, campaign, country) +ENGINE_TTL cohort_date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_traffic_daily_cold_v2.datasource b/scripts/analytics/tinybird/datasources/product_traffic_daily_cold_v2.datasource new file mode 100644 index 00000000000..94ed1574d5c --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_traffic_daily_cold_v2.datasource @@ -0,0 +1,36 @@ +DESCRIPTION > + Settled long-lived traffic-session partitions with one active generation per UTC date. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + source_cutoff DateTime64(3), + generation_id String, + is_marker UInt8, + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + date Date, + platform LowCardinality(String), + app_version LowCardinality(String), + hostname LowCardinality(String), + country LowCardinality(String), + device LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + channel LowCardinality(String), + attribution_source LowCardinality(String), + attribution_medium LowCardinality(String), + campaign LowCardinality(String), + visitors AggregateFunction(uniqExact, String), + visits UInt64, + pageviews UInt64, + bounces UInt64, + visit_duration_ms UInt64, + engaged_ms UInt64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY (generation_id, is_marker, synthetic_run_id, date, platform, app_version, hostname, country, device, browser, os, channel, attribution_source, attribution_medium, campaign) +ENGINE_TTL date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_traffic_daily_exact.datasource b/scripts/analytics/tinybird/datasources/product_traffic_daily_exact.datasource new file mode 100644 index 00000000000..c431d05b520 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_traffic_daily_exact.datasource @@ -0,0 +1,33 @@ +DESCRIPTION > + Eight-hundred-day privacy-safe daily visit metrics with exact visitor states and no raw identifiers. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + date Date, + platform LowCardinality(String), + app_version LowCardinality(String), + hostname LowCardinality(String), + country LowCardinality(String), + device LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + channel LowCardinality(String), + attribution_source LowCardinality(String), + attribution_medium LowCardinality(String), + campaign LowCardinality(String), + visitors AggregateFunction(uniqExact, String), + visits UInt64, + pageviews UInt64, + bounces UInt64, + visit_duration_ms UInt64, + engaged_ms UInt64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY (copy_run_id, synthetic_run_id, date, platform, app_version, hostname, country, device, browser, os, channel, attribution_source, attribution_medium, campaign) +ENGINE_TTL date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_traffic_daily_hot_v2.datasource b/scripts/analytics/tinybird/datasources/product_traffic_daily_hot_v2.datasource new file mode 100644 index 00000000000..3acf1419b38 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_traffic_daily_hot_v2.datasource @@ -0,0 +1,36 @@ +DESCRIPTION > + Bounded mutable 30-minute traffic-session window rebuilt from canonical event states. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + source_cutoff DateTime64(3), + generation_id String, + is_marker UInt8, + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + date Date, + platform LowCardinality(String), + app_version LowCardinality(String), + hostname LowCardinality(String), + country LowCardinality(String), + device LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + channel LowCardinality(String), + attribution_source LowCardinality(String), + attribution_medium LowCardinality(String), + campaign LowCardinality(String), + visitors AggregateFunction(uniqExact, String), + visits UInt64, + pageviews UInt64, + bounces UInt64, + visit_duration_ms UInt64, + engaged_ms UInt64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY (generation_id, is_marker, synthetic_run_id, date, platform, app_version, hostname, country, device, browser, os, channel, attribution_source, attribution_medium, campaign) +ENGINE_TTL date + INTERVAL 16 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_traffic_pages_daily_cold_v2.datasource b/scripts/analytics/tinybird/datasources/product_traffic_pages_daily_cold_v2.datasource new file mode 100644 index 00000000000..f6ee4d96e78 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_traffic_pages_daily_cold_v2.datasource @@ -0,0 +1,35 @@ +DESCRIPTION > + Settled long-lived page and engagement partitions with one active generation per UTC date. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + source_cutoff DateTime64(3), + generation_id String, + is_marker UInt8, + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + date Date, + platform LowCardinality(String), + app_version LowCardinality(String), + hostname LowCardinality(String), + pathname String, + country LowCardinality(String), + device LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + channel LowCardinality(String), + visitors AggregateFunction(uniqExact, String), + visits AggregateFunction(uniqExact, String), + pageviews UInt64, + landings UInt64, + exits UInt64, + engaged_ms UInt64, + scroll_depth_sum Float64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY (generation_id, is_marker, synthetic_run_id, date, platform, app_version, hostname, pathname, country, device, browser, os, channel) +ENGINE_TTL date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_traffic_pages_daily_exact.datasource b/scripts/analytics/tinybird/datasources/product_traffic_pages_daily_exact.datasource new file mode 100644 index 00000000000..458c65bd0c8 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_traffic_pages_daily_exact.datasource @@ -0,0 +1,32 @@ +DESCRIPTION > + Eight-hundred-day privacy-safe page, landing, exit, and foreground-engagement metrics. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + date Date, + platform LowCardinality(String), + app_version LowCardinality(String), + hostname LowCardinality(String), + pathname String, + country LowCardinality(String), + device LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + channel LowCardinality(String), + visitors AggregateFunction(uniqExact, String), + visits AggregateFunction(uniqExact, String), + pageviews UInt64, + landings UInt64, + exits UInt64, + engaged_ms UInt64, + scroll_depth_sum Float64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY (copy_run_id, synthetic_run_id, date, platform, app_version, hostname, pathname, country, device, browser, os, channel) +ENGINE_TTL date + INTERVAL 800 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/datasources/product_traffic_pages_daily_hot_v2.datasource b/scripts/analytics/tinybird/datasources/product_traffic_pages_daily_hot_v2.datasource new file mode 100644 index 00000000000..ac76022e505 --- /dev/null +++ b/scripts/analytics/tinybird/datasources/product_traffic_pages_daily_hot_v2.datasource @@ -0,0 +1,35 @@ +DESCRIPTION > + Bounded mutable page, landing, exit, and foreground-engagement window. + +TOKEN product_events_copy_runner APPEND + +SCHEMA > + source_cutoff DateTime64(3), + generation_id String, + is_marker UInt8, + calculated_at DateTime64(3), + copy_run_id String, + synthetic_run_id String, + date Date, + platform LowCardinality(String), + app_version LowCardinality(String), + hostname LowCardinality(String), + pathname String, + country LowCardinality(String), + device LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + channel LowCardinality(String), + visitors AggregateFunction(uniqExact, String), + visits AggregateFunction(uniqExact, String), + pageviews UInt64, + landings UInt64, + exits UInt64, + engaged_ms UInt64, + scroll_depth_sum Float64 + +ENGINE AggregatingMergeTree +ENGINE_PARTITION_KEY toYYYYMM(date) +ENGINE_SORTING_KEY (generation_id, is_marker, synthetic_run_id, date, platform, app_version, hostname, pathname, country, device, browser, os, channel) +ENGINE_TTL date + INTERVAL 16 DAY +ENGINE_SETTINGS index_granularity = 256 diff --git a/scripts/analytics/tinybird/fixtures/product_events_v1.ndjson b/scripts/analytics/tinybird/fixtures/product_events_v1.ndjson new file mode 100644 index 00000000000..15a237de966 --- /dev/null +++ b/scripts/analytics/tinybird/fixtures/product_events_v1.ndjson @@ -0,0 +1,15 @@ +{"event_id":"evt_fixture_signup_1","payload_hash":"11111111111111111111111111111111","occurred_at":"2099-01-10 09:00:00.000","received_at":"2099-01-10 09:00:00.100","event_name":"user_signed_up","schema_version":1,"source":"server","platform":"web","anonymous_id":"anon_fixture_1","session_id":"session_fixture_1","user_id":"user_fixture_1","organization_id":"org_fixture_1","app_version":"web","pathname":"/dashboard","referrer":"cap.so","country":"US","region":"CA","city":"San Francisco","hostname":"cap.so","browser":"Chrome","device":"desktop","os":"macOS","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{}"} +{"event_id":"evt_fixture_recording_1","payload_hash":"22222222222222222222222222222222","occurred_at":"2099-01-10 09:05:00.000","received_at":"2099-01-10 09:05:00.100","event_name":"recording_started","schema_version":1,"source":"client","platform":"desktop","anonymous_id":"anon_fixture_1","session_id":"session_fixture_1","user_id":"user_fixture_1","organization_id":"org_fixture_1","app_version":"1.0.0","pathname":"","referrer":"","country":"US","region":"CA","city":"San Francisco","hostname":"","browser":"","device":"desktop","os":"macOS","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{\"mode\":\"screen\"}"} +{"event_id":"evt_fixture_recording_2","payload_hash":"33333333333333333333333333333333","occurred_at":"2099-01-10 09:10:00.000","received_at":"2099-01-10 09:10:00.100","event_name":"recording_started","schema_version":1,"source":"client","platform":"desktop","anonymous_id":"anon_fixture_2","session_id":"session_fixture_2","user_id":"","organization_id":"","app_version":"1.0.0","pathname":"","referrer":"","country":"GB","region":"ENG","city":"London","hostname":"","browser":"","device":"desktop","os":"Windows","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{\"mode\":\"camera\"}"} +{"event_id":"evt_fixture_purchase_1","payload_hash":"44444444444444444444444444444444","occurred_at":"2099-01-11 10:00:00.000","received_at":"2099-01-11 10:00:00.100","event_name":"purchase_completed","schema_version":1,"source":"server","platform":"web","anonymous_id":"anon_fixture_1","session_id":"session_fixture_1","user_id":"user_fixture_1","organization_id":"org_fixture_1","app_version":"web","pathname":"/dashboard/settings/billing","referrer":"","country":"US","region":"CA","city":"San Francisco","hostname":"cap.so","browser":"Chrome","device":"desktop","os":"macOS","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{\"payment_status\":\"paid\",\"subscription_status\":\"active\",\"amount_total_minor\":2500,\"currency\":\"usd\",\"billing_interval\":\"month\"}"} +{"event_id":"evt_fixture_trial_1","payload_hash":"55555555555555555555555555555555","occurred_at":"2099-01-11 11:00:00.000","received_at":"2099-01-11 11:00:00.100","event_name":"trial_started","schema_version":1,"source":"server","platform":"web","anonymous_id":"anon_fixture_trial","session_id":"session_fixture_trial","user_id":"user_fixture_trial","organization_id":"org_fixture_trial","app_version":"web","pathname":"/dashboard/settings/billing","referrer":"","country":"US","region":"NY","city":"New York","hostname":"cap.so","browser":"Chrome","device":"desktop","os":"macOS","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{\"subscription_status\":\"trialing\",\"currency\":\"usd\",\"billing_interval\":\"month\"}"} +{"event_id":"evt_fixture_page_1","payload_hash":"66666666666666666666666666666666","occurred_at":"2099-01-10 10:00:00.000","received_at":"2099-01-10 10:00:00.100","event_name":"page_view","schema_version":1,"source":"client","platform":"web","anonymous_id":"anon_traffic_1","session_id":"visit_fixture_1","user_id":"","organization_id":"","app_version":"web","pathname":"/","referrer":"","country":"US","region":"CA","city":"San Francisco","hostname":"cap.so","browser":"Chrome","device":"desktop","os":"macOS","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{\"hostname\":\"cap.so\",\"is_session_entry\":true,\"session_started_at\":\"2026-07-29T10:00:00.000Z\"}"} +{"event_id":"evt_fixture_engagement_1","payload_hash":"77777777777777777777777777777777","occurred_at":"2099-01-10 10:00:05.000","received_at":"2099-01-10 10:00:05.100","event_name":"page_engagement","schema_version":1,"source":"client","platform":"web","anonymous_id":"anon_traffic_1","session_id":"visit_fixture_1","user_id":"","organization_id":"","app_version":"web","pathname":"/","referrer":"","country":"US","region":"CA","city":"San Francisco","hostname":"cap.so","browser":"Chrome","device":"desktop","os":"macOS","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{\"page_view_id\":\"evt_fixture_page_1\",\"engaged_ms\":5000,\"max_scroll_depth\":30,\"session_started_at\":\"2026-07-29T10:00:00.000Z\"}"} +{"event_id":"evt_fixture_page_2","payload_hash":"88888888888888888888888888888888","occurred_at":"2099-01-10 10:10:00.000","received_at":"2099-01-10 10:10:00.100","event_name":"page_view","schema_version":1,"source":"client","platform":"web","anonymous_id":"anon_traffic_2","session_id":"visit_fixture_2","user_id":"","organization_id":"","app_version":"web","pathname":"/pricing","referrer":"google.com","country":"GB","region":"ENG","city":"London","hostname":"cap.so","browser":"Safari","device":"mobile","os":"iOS","channel":"organic_search","traffic_class":"external","synthetic_run_id":"","properties":"{\"hostname\":\"cap.so\",\"is_session_entry\":true,\"session_started_at\":\"2026-07-29T10:10:00.000Z\",\"session_touch_source\":\"google\",\"session_touch_medium\":\"organic\",\"session_touch_campaign\":\"launch\"}"} +{"event_id":"evt_fixture_engagement_2","payload_hash":"99999999999999999999999999999999","occurred_at":"2099-01-10 10:10:15.000","received_at":"2099-01-10 10:10:15.100","event_name":"page_engagement","schema_version":1,"source":"client","platform":"web","anonymous_id":"anon_traffic_2","session_id":"visit_fixture_2","user_id":"","organization_id":"","app_version":"web","pathname":"/pricing","referrer":"google.com","country":"GB","region":"ENG","city":"London","hostname":"cap.so","browser":"Safari","device":"mobile","os":"iOS","channel":"organic_search","traffic_class":"external","synthetic_run_id":"","properties":"{\"page_view_id\":\"evt_fixture_page_2\",\"engaged_ms\":15000,\"max_scroll_depth\":80,\"session_started_at\":\"2026-07-29T10:10:00.000Z\"}"} +{"event_id":"evt_fixture_page_3","payload_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","occurred_at":"2099-01-10 10:12:00.000","received_at":"2099-01-10 10:12:00.100","event_name":"page_view","schema_version":1,"source":"client","platform":"web","anonymous_id":"anon_traffic_2","session_id":"visit_fixture_2","user_id":"","organization_id":"","app_version":"web","pathname":"/download","referrer":"google.com","country":"GB","region":"ENG","city":"London","hostname":"cap.so","browser":"Safari","device":"mobile","os":"iOS","channel":"organic_search","traffic_class":"external","synthetic_run_id":"","properties":"{\"hostname\":\"cap.so\",\"is_session_entry\":false,\"session_started_at\":\"2026-07-29T10:10:00.000Z\",\"session_touch_source\":\"google\",\"session_touch_medium\":\"organic\",\"session_touch_campaign\":\"launch\"}"} +{"event_id":"evt_fixture_engagement_3","payload_hash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","occurred_at":"2099-01-10 10:12:03.000","received_at":"2099-01-10 10:12:03.100","event_name":"page_engagement","schema_version":1,"source":"client","platform":"web","anonymous_id":"anon_traffic_2","session_id":"visit_fixture_2","user_id":"","organization_id":"","app_version":"web","pathname":"/download","referrer":"google.com","country":"GB","region":"ENG","city":"London","hostname":"cap.so","browser":"Safari","device":"mobile","os":"iOS","channel":"organic_search","traffic_class":"external","synthetic_run_id":"","properties":"{\"page_view_id\":\"evt_fixture_page_3\",\"engaged_ms\":3000,\"max_scroll_depth\":20,\"session_started_at\":\"2026-07-29T10:10:00.000Z\"}"} +{"event_id":"evt_fixture_recording_d1_desktop","payload_hash":"cccccccccccccccccccccccccccccccc","occurred_at":"2099-01-11 09:00:00.000","received_at":"2099-01-11 09:00:00.100","event_name":"recording_started","schema_version":1,"source":"client","platform":"desktop","anonymous_id":"anon_fixture_1","session_id":"session_fixture_1","user_id":"user_fixture_1","organization_id":"org_fixture_1","app_version":"1.0.0","pathname":"","referrer":"","country":"US","region":"CA","city":"San Francisco","hostname":"","browser":"","device":"desktop","os":"macOS","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{\"mode\":\"screen\",\"target_kind\":\"display\",\"has_camera\":false,\"has_mic\":true,\"has_system_audio\":true,\"target_fps\":30,\"target_width\":1920,\"target_height\":1080,\"fragmented\":true,\"custom_cursor_capture\":true}"} +{"event_id":"evt_fixture_recording_d1_mobile","payload_hash":"dddddddddddddddddddddddddddddddd","occurred_at":"2099-01-11 09:05:00.000","received_at":"2099-01-11 09:05:00.100","event_name":"recording_started","schema_version":1,"source":"client","platform":"mobile","anonymous_id":"anon_fixture_1","session_id":"session_fixture_mobile","user_id":"user_fixture_1","organization_id":"org_fixture_1","app_version":"1.0.0","pathname":"","referrer":"","country":"US","region":"CA","city":"San Francisco","hostname":"","browser":"","device":"mobile","os":"iOS","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{\"mode\":\"screen\",\"target_kind\":\"display\",\"has_camera\":false,\"has_mic\":true,\"has_system_audio\":false,\"target_fps\":30,\"target_width\":1179,\"target_height\":2556,\"fragmented\":true,\"custom_cursor_capture\":false}"} +{"event_id":"evt_fixture_checkout_web","payload_hash":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","occurred_at":"2099-01-12 09:00:00.000","received_at":"2099-01-12 09:00:00.100","event_name":"checkout_started","schema_version":1,"source":"server","platform":"web","anonymous_id":"anon_fixture_1","session_id":"session_fixture_web_checkout","user_id":"user_fixture_1","organization_id":"org_fixture_1","app_version":"web","pathname":"/pricing","referrer":"","country":"US","region":"CA","city":"San Francisco","hostname":"cap.so","browser":"Chrome","device":"desktop","os":"macOS","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{\"price_id\":\"price_fixture\",\"quantity\":1,\"is_onboarding\":false}"} +{"event_id":"evt_fixture_checkout_desktop","payload_hash":"ffffffffffffffffffffffffffffffff","occurred_at":"2099-01-12 09:05:00.000","received_at":"2099-01-12 09:05:00.100","event_name":"checkout_started","schema_version":1,"source":"server","platform":"desktop","anonymous_id":"anon_fixture_1","session_id":"session_fixture_desktop_checkout","user_id":"user_fixture_1","organization_id":"org_fixture_1","app_version":"1.0.0","pathname":"","referrer":"","country":"US","region":"CA","city":"San Francisco","hostname":"","browser":"","device":"desktop","os":"macOS","channel":"direct","traffic_class":"external","synthetic_run_id":"","properties":"{\"price_id\":\"price_fixture\",\"quantity\":1,\"is_onboarding\":false}"} diff --git a/scripts/analytics/tinybird/pipes/materialize_product_event_day_states_v2.pipe b/scripts/analytics/tinybird/pipes/materialize_product_event_day_states_v2.pipe new file mode 100644 index 00000000000..a26a44cf475 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/materialize_product_event_day_states_v2.pipe @@ -0,0 +1,40 @@ +DESCRIPTION > + Continuously merge retry attempts into exact event states partitioned by UTC occurrence date. + +NODE materialize_product_event_day_states_v2_node +SQL > + SELECT + toDate(raw.occurred_at, 'UTC') AS occurred_date, + raw.event_id, + uniqExactState(raw.payload_hash) AS payload_hashes, + argMinState(raw.payload_hash, raw.received_at) AS payload_hash, + argMinState(raw.occurred_at, raw.received_at) AS occurred_at, + min(raw.received_at) AS first_received_at, + max(raw.received_at) AS received_at, + argMinState(toString(raw.event_name), raw.received_at) AS event_name, + argMinState(raw.schema_version, raw.received_at) AS schema_version, + argMinState(toString(raw.source), raw.received_at) AS source, + argMinState(toString(raw.platform), raw.received_at) AS platform, + argMinState(raw.anonymous_id, raw.received_at) AS anonymous_id, + argMinState(raw.session_id, raw.received_at) AS session_id, + argMinState(raw.user_id, raw.received_at) AS user_id, + argMinState(raw.organization_id, raw.received_at) AS organization_id, + argMinState(toString(raw.app_version), raw.received_at) AS app_version, + argMinState(raw.pathname, raw.received_at) AS pathname, + argMinState(raw.referrer, raw.received_at) AS referrer, + argMinState(toString(raw.country), raw.received_at) AS country, + argMinState(toString(raw.region), raw.received_at) AS region, + argMinState(toString(raw.city), raw.received_at) AS city, + argMinState(toString(raw.hostname), raw.received_at) AS hostname, + argMinState(toString(raw.browser), raw.received_at) AS browser, + argMinState(toString(raw.device), raw.received_at) AS device, + argMinState(toString(raw.os), raw.received_at) AS os, + argMinState(toString(raw.channel), raw.received_at) AS channel, + argMinState(toString(raw.traffic_class), raw.received_at) AS traffic_class, + argMinState(raw.synthetic_run_id, raw.received_at) AS synthetic_run_id, + argMinState(raw.properties, raw.received_at) AS properties + FROM product_events_v1 AS raw + GROUP BY toDate(raw.occurred_at, 'UTC'), raw.event_id + +TYPE MATERIALIZED +DATASOURCE product_event_day_states_v2 diff --git a/scripts/analytics/tinybird/pipes/materialize_product_event_id_states_v2.pipe b/scripts/analytics/tinybird/pipes/materialize_product_event_id_states_v2.pipe new file mode 100644 index 00000000000..a9b219e008c --- /dev/null +++ b/scripts/analytics/tinybird/pipes/materialize_product_event_id_states_v2.pipe @@ -0,0 +1,39 @@ +DESCRIPTION > + Continuously merge retry attempts into one exact aggregate state per immutable event ID. + +NODE materialize_product_event_id_states_v2_node +SQL > + SELECT + raw.event_id, + uniqExactState(raw.payload_hash) AS payload_hashes, + argMinState(raw.payload_hash, raw.received_at) AS payload_hash, + argMinState(raw.occurred_at, raw.received_at) AS occurred_at, + min(raw.received_at) AS first_received_at, + max(raw.received_at) AS received_at, + argMinState(toString(raw.event_name), raw.received_at) AS event_name, + argMinState(raw.schema_version, raw.received_at) AS schema_version, + argMinState(toString(raw.source), raw.received_at) AS source, + argMinState(toString(raw.platform), raw.received_at) AS platform, + argMinState(raw.anonymous_id, raw.received_at) AS anonymous_id, + argMinState(raw.session_id, raw.received_at) AS session_id, + argMinState(raw.user_id, raw.received_at) AS user_id, + argMinState(raw.organization_id, raw.received_at) AS organization_id, + argMinState(toString(raw.app_version), raw.received_at) AS app_version, + argMinState(raw.pathname, raw.received_at) AS pathname, + argMinState(raw.referrer, raw.received_at) AS referrer, + argMinState(toString(raw.country), raw.received_at) AS country, + argMinState(toString(raw.region), raw.received_at) AS region, + argMinState(toString(raw.city), raw.received_at) AS city, + argMinState(toString(raw.hostname), raw.received_at) AS hostname, + argMinState(toString(raw.browser), raw.received_at) AS browser, + argMinState(toString(raw.device), raw.received_at) AS device, + argMinState(toString(raw.os), raw.received_at) AS os, + argMinState(toString(raw.channel), raw.received_at) AS channel, + argMinState(toString(raw.traffic_class), raw.received_at) AS traffic_class, + argMinState(raw.synthetic_run_id, raw.received_at) AS synthetic_run_id, + argMinState(raw.properties, raw.received_at) AS properties + FROM product_events_v1 AS raw + GROUP BY raw.event_id + +TYPE MATERIALIZED +DATASOURCE product_event_id_states_v2 diff --git a/scripts/analytics/tinybird/pipes/materialize_product_events_health_hourly.pipe b/scripts/analytics/tinybird/pipes/materialize_product_events_health_hourly.pipe new file mode 100644 index 00000000000..7cecec0808d --- /dev/null +++ b/scripts/analytics/tinybird/pipes/materialize_product_events_health_hourly.pipe @@ -0,0 +1,23 @@ +DESCRIPTION > + Continuously aggregate delivery attempts, duplicates, clock skew, identity gaps, and ingestion lag. + +NODE materialize_product_events_health_hourly_node +SQL > + SELECT + toStartOfHour(received_at) AS hour, + now64(3) AS calculated_at, + '' AS copy_run_id, + platform, + app_version, + count() AS received_rows, + uniqExactState(event_id) AS unique_events, + uniqExactState(tuple(event_id, payload_hash)) AS unique_payloads, + countIf(occurred_at > received_at + INTERVAL 5 MINUTE) AS future_events, + countIf(occurred_at < received_at - INTERVAL 1 DAY) AS late_events, + countIf(user_id = '' AND anonymous_id = '' AND session_id = '') AS missing_identity_events, + quantilesTDigestState(0.5, 0.95, 0.99)(toFloat64(dateDiff('millisecond', occurred_at, received_at))) AS ingestion_lag_ms + FROM product_events_v1 + GROUP BY hour, platform, app_version + +TYPE MATERIALIZED +DATASOURCE product_events_health_hourly_exact diff --git a/scripts/analytics/tinybird/pipes/product_activation.pipe b/scripts/analytics/tinybird/pipes/product_activation.pipe new file mode 100644 index 00000000000..bdb1647c077 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_activation.pipe @@ -0,0 +1,28 @@ +DESCRIPTION > + Query signup cohorts and first-share activation within seven UTC days. + + +NODE product_activation_node +SQL > + % + SELECT + cohort_date, + uniqExactMerge(signups) AS signups, + uniqExactMerge(activated_creators) AS activated_creators, + round(if(signups = 0, 0, 100 * activated_creators / signups), 2) AS activation_rate, + toUInt64(if(activated_creators = 0, 0, sum(time_to_activation_ms) / activated_creators)) AS average_time_to_activation_ms + FROM product_activation_daily_exact + WHERE copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND cohort_date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 90 DAY {% end %} + AND cohort_date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + GROUP BY cohort_date + ORDER BY cohort_date ASC + LIMIT 800 + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_analytics_ci_assertions.pipe b/scripts/analytics/tinybird/pipes/product_analytics_ci_assertions.pipe new file mode 100644 index 00000000000..393d1e2983f --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_analytics_ci_assertions.pipe @@ -0,0 +1,135 @@ +DESCRIPTION > + Prove synthetic retry deduplication and payload-conflict quarantine without exposing raw event identifiers. + + +NODE product_analytics_ci_assertions_node +SQL > + % + {% if not defined(synthetic_run_id) %} + {{ error('synthetic_run_id is required') }} + {% end %} + WITH per_event AS ( + SELECT + event_id, + count() AS deliveries, + uniqExact(payload_hash) AS payloads + FROM product_events_v1 + WHERE synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + GROUP BY event_id + ), raw_metrics AS ( + SELECT + sum(deliveries) AS raw_received_rows, + count() AS raw_unique_events, + sum(payloads) AS raw_unique_payloads, + sum(deliveries) - sum(payloads) AS raw_duplicate_rows, + sum(payloads) - count() AS raw_payload_conflicts + FROM per_event + ), canonical_metrics AS ( + SELECT count() AS canonical_events + FROM product_events_canonical_v1 + WHERE synthetic_run_id = {{String(synthetic_run_id)}} + ), decision_metrics AS ( + SELECT + toUInt64(coalesce(sum(events), 0)) AS decision_events, + toInt64(coalesce(sum(revenue_minor), 0)) AS decision_revenue_minor + FROM product_events_daily_exact + WHERE synthetic_run_id = {{String(synthetic_run_id)}} + ), traffic_metrics AS ( + SELECT + uniqExactMerge(visitors) AS traffic_visitors, + toUInt64(sum(visits)) AS traffic_visits, + toUInt64(sum(pageviews)) AS traffic_pageviews, + toUInt64(sum(bounces)) AS traffic_bounces, + toUInt64(sum(visit_duration_ms)) AS traffic_duration_ms + FROM product_traffic_daily_exact + WHERE synthetic_run_id = {{String(synthetic_run_id)}} + ), page_metrics AS ( + SELECT + uniqExactMerge(visitors) AS page_visitors, + uniqExactMerge(visits) AS page_visits, + toUInt64(sum(pageviews)) AS pageviews, + toUInt64(sum(landings)) AS page_landings, + toUInt64(sum(exits)) AS page_exits, + toUInt64(sum(engaged_ms)) AS page_engaged_ms, + toFloat64(sum(scroll_depth_sum)) AS page_scroll_depth + FROM product_traffic_pages_daily_exact + WHERE synthetic_run_id = {{String(synthetic_run_id)}} + ), activation_metrics AS ( + SELECT + uniqExactMerge(signups) AS activation_signups, + uniqExactMerge(activated_creators) AS activated_creators + FROM product_activation_daily_exact + WHERE synthetic_run_id = {{String(synthetic_run_id)}} + ), retention_metrics AS ( + SELECT + uniqExactMerge(creator_users) AS retention_creators, + uniqExactMerge(creator_organizations) AS retention_organizations + FROM product_creator_retention_exact + WHERE synthetic_run_id = {{String(synthetic_run_id)}} + ), identity_metrics AS ( + SELECT + toUInt64(coalesce(sum(linked_visitors), 0)) AS identity_linked_visitors, + toUInt64(coalesce(sum(linked_users), 0)) AS identity_linked_users, + toUInt64(coalesce(sum(signup_users), 0)) AS identity_signup_users, + toUInt64(coalesce(sum(organizations), 0)) AS identity_organizations, + toUInt64(coalesce(sum(guest_checkout_visitors), 0)) AS identity_guest_checkout_visitors, + toUInt64(coalesce(sum(guest_purchasers), 0)) AS identity_guest_purchasers, + toUInt64(coalesce(sum(authenticated_checkout_users), 0)) AS identity_authenticated_checkout_users, + toUInt64(coalesce(sum(web_checkout_users), 0)) AS identity_web_checkout_users, + toUInt64(coalesce(sum(desktop_checkout_users), 0)) AS identity_desktop_checkout_users, + toUInt64(coalesce(sum(mobile_checkout_users), 0)) AS identity_mobile_checkout_users, + toUInt64(coalesce(sum(cross_device_checkout_users), 0)) AS identity_cross_device_checkout_users, + toUInt64(coalesce(sum(trial_users), 0)) AS identity_trial_users, + toUInt64(coalesce(sum(purchasers), 0)) AS identity_purchasers + FROM product_identity_funnel_exact + WHERE synthetic_run_id = {{String(synthetic_run_id)}} + ) + SELECT + raw_received_rows AS received_rows, + raw_unique_events AS unique_events, + raw_unique_payloads AS unique_payloads, + raw_duplicate_rows AS duplicate_rows, + raw_payload_conflicts AS payload_conflicts, + canonical_events, + decision_events, + decision_revenue_minor, + traffic_visitors, + traffic_visits, + traffic_pageviews, + traffic_bounces, + traffic_duration_ms, + page_visitors, + page_visits, + pageviews, + page_landings, + page_exits, + page_engaged_ms, + page_scroll_depth, + activation_signups, + activated_creators, + retention_creators, + retention_organizations, + identity_linked_visitors, + identity_linked_users, + identity_signup_users, + identity_organizations, + identity_guest_checkout_visitors, + identity_guest_purchasers, + identity_authenticated_checkout_users, + identity_web_checkout_users, + identity_desktop_checkout_users, + identity_mobile_checkout_users, + identity_cross_device_checkout_users, + identity_trial_users, + identity_purchasers + FROM raw_metrics + CROSS JOIN canonical_metrics + CROSS JOIN decision_metrics + CROSS JOIN traffic_metrics + CROSS JOIN page_metrics + CROSS JOIN activation_metrics + CROSS JOIN retention_metrics + CROSS JOIN identity_metrics + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_analytics_copy_assertions.pipe b/scripts/analytics/tinybird/pipes/product_analytics_copy_assertions.pipe new file mode 100644 index 00000000000..c34608e471c --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_analytics_copy_assertions.pipe @@ -0,0 +1,23 @@ +DESCRIPTION > + Prove each privacy-safe aggregate copy completed for one bounded staging phase. + + +NODE product_analytics_copy_assertions_node +SQL > + % + {% if not defined(copy_run_id) %} + {{ error('copy_run_id is required') }} + {% end %} + SELECT + (SELECT count() FROM product_traffic_daily_exact WHERE copy_run_id = {{String(copy_run_id)}}) AS traffic_markers, + (SELECT count() FROM product_events_daily_exact WHERE copy_run_id = {{String(copy_run_id)}}) AS decision_markers, + (SELECT count() FROM product_traffic_pages_daily_exact WHERE copy_run_id = {{String(copy_run_id)}}) AS traffic_page_markers, + (SELECT count() FROM product_activation_daily_exact WHERE copy_run_id = {{String(copy_run_id)}}) AS activation_markers, + (SELECT count() FROM product_creator_retention_exact WHERE copy_run_id = {{String(copy_run_id)}}) AS retention_markers, + (SELECT count() FROM product_identity_funnel_exact WHERE copy_run_id = {{String(copy_run_id)}}) AS identity_markers, + (SELECT count() FROM product_attribution_daily_exact WHERE copy_run_id = {{String(copy_run_id)}}) AS attribution_markers, + (SELECT count() FROM product_experiment_outcomes_exact WHERE copy_run_id = {{String(copy_run_id)}} AND copy_marker = 1) AS experiment_markers, + (SELECT count() FROM product_events_health_hourly_exact WHERE copy_run_id = {{String(copy_run_id)}}) AS health_markers + WHERE throwIf(length({{String(copy_run_id)}}) < 8 OR length({{String(copy_run_id)}}) > 128, 'copy_run_id has an invalid length') = 0 + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_analytics_freshness.pipe b/scripts/analytics/tinybird/pipes/product_analytics_freshness.pipe new file mode 100644 index 00000000000..f33ebbaf704 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_analytics_freshness.pipe @@ -0,0 +1,19 @@ +DESCRIPTION > + Expose aggregate freshness and bounded raw ingestion lag without identifiers. + + +NODE product_analytics_freshness_node +SQL > + SELECT + max(hour) AS latest_received_hour, + dateDiff('millisecond', max(hour), now64(3)) AS health_freshness_ms, + (SELECT max(calculated_at) FROM product_events_daily_exact WHERE copy_run_id = '') AS product_calculated_at, + (SELECT max(calculated_at) FROM product_traffic_daily_exact) AS traffic_calculated_at, + (SELECT max(calculated_at) FROM product_creator_retention_exact) AS retention_calculated_at, + (SELECT max(calculated_at) FROM product_identity_funnel_exact) AS identity_calculated_at, + (SELECT max(calculated_at) FROM product_attribution_daily_exact) AS attribution_calculated_at, + (SELECT max(calculated_at) FROM product_experiment_outcomes_exact) AS experiment_calculated_at + FROM product_events_health_hourly_exact + WHERE copy_run_id = '' + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_attribution.pipe b/scripts/analytics/tinybird/pipes/product_attribution.pipe new file mode 100644 index 00000000000..c786b422694 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_attribution.pipe @@ -0,0 +1,39 @@ +DESCRIPTION > + Query aggregate first, session, and last campaign attribution without returning visitor or session identifiers. + + +NODE product_attribution_node +SQL > + % + SELECT + attribution_model, + attribution_source AS source, + attribution_medium AS medium, + campaign, + uniqExactMerge(visitors) AS visitors, + toUInt64(sum(visits)) AS visits, + toUInt64(sum(pageviews)) AS pageviews + FROM product_attribution_daily_exact + WHERE copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(attribution_model) %} + AND attribution_model = {{String(attribution_model)}} + AND throwIf(NOT has(['first', 'session', 'last'], {{String(attribution_model)}}), 'attribution_model must be first, session, or last') = 0 + {% end %} + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + {% if defined(hostname) %} AND hostname = lower({{String(hostname)}}) {% end %} + {% if defined(country) %} AND country = upper({{String(country)}}) {% end %} + {% if defined(source) %} AND attribution_source = {{String(source)}} {% end %} + GROUP BY attribution_model, attribution_source, attribution_medium, campaign + ORDER BY attribution_model ASC, visits DESC, source ASC, medium ASC, campaign ASC + LIMIT greatest(1, least({{Int32(limit, 300)}}, 1000)) + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_creator_activity.pipe b/scripts/analytics/tinybird/pipes/product_creator_activity.pipe new file mode 100644 index 00000000000..46a9b15b152 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_creator_activity.pipe @@ -0,0 +1,30 @@ +DESCRIPTION > + Query active creators, organizations, new-versus-returning activity, and DAU-WAU-MAU stickiness. + + +NODE product_creator_activity_node +SQL > + % + SELECT + {% if defined(as_of_date) %} {{Date(as_of_date)}} {% else %} today() {% end %} AS as_of_date, + uniqExactMergeIf(creator_users, activity_date = as_of_date) AS dau, + uniqExactMergeIf(creator_users, activity_date >= as_of_date - INTERVAL 6 DAY AND activity_date <= as_of_date) AS wau, + uniqExactMergeIf(creator_users, activity_date >= as_of_date - INTERVAL 29 DAY AND activity_date <= as_of_date) AS mau, + uniqExactMergeIf(creator_organizations, activity_date = as_of_date) AS daily_active_organizations, + uniqExactMergeIf(creator_users, activity_date = cohort_date AND activity_date = as_of_date) AS new_creators, + uniqExactMergeIf(creator_users, activity_date > cohort_date AND activity_date = as_of_date) AS returning_creators, + round(if(wau = 0, 0, 100 * dau / wau), 2) AS dau_wau_stickiness, + round(if(mau = 0, 0, 100 * dau / mau), 2) AS dau_mau_stickiness + FROM product_creator_retention_exact + WHERE copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND activity_date >= as_of_date - INTERVAL 29 DAY + AND activity_date <= as_of_date + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_creator_retention.pipe b/scripts/analytics/tinybird/pipes/product_creator_retention.pipe new file mode 100644 index 00000000000..3b8a61f4bde --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_creator_retention.pipe @@ -0,0 +1,30 @@ +DESCRIPTION > + Query exact creator and organization retention cohorts with an optional activity-platform filter and an all-platform cohort baseline. + + +NODE product_creator_retention_node +SQL > + % + SELECT + cohort_date, + activity_date, + dateDiff('day', cohort_date, activity_date) AS cohort_day, + {% if defined(platform) %} {{String(platform)}} {% else %} 'all' {% end %} AS platform, + uniqExactMerge(creator_users) AS creators, + uniqExactMerge(creator_organizations) AS organizations + FROM product_creator_retention_exact + WHERE copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND cohort_date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 180 DAY {% end %} + AND cohort_date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(platform) %} AND (activity_date = cohort_date OR platform = {{String(platform)}}) {% end %} + GROUP BY cohort_date, activity_date + ORDER BY cohort_date ASC, activity_date ASC, platform ASC + LIMIT 5000 + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_events_canonical_current.pipe b/scripts/analytics/tinybird/pipes/product_events_canonical_current.pipe new file mode 100644 index 00000000000..d5bed993f34 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_events_canonical_current.pipe @@ -0,0 +1,39 @@ +DESCRIPTION > + Finalize streaming event states, count exact retries once, and quarantine payload conflicts. + +TOKEN product_events_copy_runner READ + +NODE canonical_current +SQL > + % + SELECT + event_id, + argMinMerge(payload_hash) AS payload_hash, + argMinMerge(occurred_at) AS occurred_at, + max(received_at) AS received_at, + argMinMerge(event_name) AS event_name, + argMinMerge(schema_version) AS schema_version, + argMinMerge(source) AS source, + argMinMerge(platform) AS platform, + argMinMerge(anonymous_id) AS anonymous_id, + argMinMerge(session_id) AS session_id, + argMinMerge(user_id) AS user_id, + argMinMerge(organization_id) AS organization_id, + argMinMerge(app_version) AS app_version, + argMinMerge(pathname) AS pathname, + argMinMerge(referrer) AS referrer, + argMinMerge(country) AS country, + argMinMerge(region) AS region, + argMinMerge(city) AS city, + argMinMerge(hostname) AS hostname, + argMinMerge(browser) AS browser, + argMinMerge(device) AS device, + argMinMerge(os) AS os, + argMinMerge(channel) AS channel, + argMinMerge(traffic_class) AS traffic_class, + argMinMerge(synthetic_run_id) AS synthetic_run_id, + argMinMerge(properties) AS properties + FROM product_event_id_states_v2 + WHERE first_received_at <= {% if defined(source_cutoff) %} toDateTime64({{DateTime64(source_cutoff)}}, 3) {% else %} now64(3) {% end %} + GROUP BY event_id + HAVING uniqExactMerge(payload_hashes) = 1 diff --git a/scripts/analytics/tinybird/pipes/product_events_canonical_window.pipe b/scripts/analytics/tinybird/pipes/product_events_canonical_window.pipe new file mode 100644 index 00000000000..51bad1cab51 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_events_canonical_window.pipe @@ -0,0 +1,60 @@ +DESCRIPTION > + Finalize one bounded UTC event window while retaining global payload-conflict quarantine. + +TOKEN product_events_copy_runner READ + +NODE product_events_canonical_window_node +SQL > + % + {% if not defined(source_cutoff) %} + {{ error('source_cutoff is required') }} + {% end %} + WITH candidate_ids AS ( + SELECT event_id + FROM product_event_day_states_v2 + WHERE occurred_date >= {% if defined(settled_date) %} toDate({{Date(settled_date)}}) - INTERVAL 1 DAY {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) - INTERVAL 9 DAY {% end %} + AND occurred_date < {% if defined(settled_date) %} toDate({{Date(settled_date)}}) + INTERVAL 2 DAY {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) + INTERVAL 1 DAY {% end %} + AND first_received_at <= toDateTime64({{DateTime64(source_cutoff)}}, 3) + GROUP BY event_id + ), valid_ids AS ( + SELECT event_id + FROM product_event_id_states_v2 + WHERE event_id IN (SELECT event_id FROM candidate_ids) + AND first_received_at <= toDateTime64({{DateTime64(source_cutoff)}}, 3) + GROUP BY event_id + HAVING uniqExactMerge(payload_hashes) = 1 + ) + SELECT + event_id, + argMinMerge(payload_hash) AS payload_hash, + argMinMerge(occurred_at) AS occurred_at, + max(received_at) AS received_at, + argMinMerge(event_name) AS event_name, + argMinMerge(schema_version) AS schema_version, + argMinMerge(source) AS source, + argMinMerge(platform) AS platform, + argMinMerge(anonymous_id) AS anonymous_id, + argMinMerge(session_id) AS session_id, + argMinMerge(user_id) AS user_id, + argMinMerge(organization_id) AS organization_id, + argMinMerge(app_version) AS app_version, + argMinMerge(pathname) AS pathname, + argMinMerge(referrer) AS referrer, + argMinMerge(country) AS country, + argMinMerge(region) AS region, + argMinMerge(city) AS city, + argMinMerge(hostname) AS hostname, + argMinMerge(browser) AS browser, + argMinMerge(device) AS device, + argMinMerge(os) AS os, + argMinMerge(channel) AS channel, + argMinMerge(traffic_class) AS traffic_class, + argMinMerge(synthetic_run_id) AS synthetic_run_id, + argMinMerge(properties) AS properties + FROM product_event_day_states_v2 + INNER JOIN valid_ids USING event_id + WHERE occurred_date >= {% if defined(settled_date) %} toDate({{Date(settled_date)}}) - INTERVAL 1 DAY {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) - INTERVAL 9 DAY {% end %} + AND occurred_date < {% if defined(settled_date) %} toDate({{Date(settled_date)}}) + INTERVAL 2 DAY {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) + INTERVAL 1 DAY {% end %} + AND first_received_at <= toDateTime64({{DateTime64(source_cutoff)}}, 3) + GROUP BY event_id + HAVING uniqExactMerge(payload_hashes) = 1 diff --git a/scripts/analytics/tinybird/pipes/product_events_daily.pipe b/scripts/analytics/tinybird/pipes/product_events_daily.pipe new file mode 100644 index 00000000000..58808091808 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_events_daily.pipe @@ -0,0 +1,97 @@ +DESCRIPTION > + Query daily product events using typed filters and pre-aggregated data. + + +NODE product_events_daily_node +SQL > + % + SELECT + date, + event_name, + schema_version, + source, + platform, + app_version, + hostname, + country, + device, + browser, + os, + channel, + plan_id, + recording_status, + payment_status, + subscription_status, + currency, + billing_interval, + change_kind, + previous_status, + new_status, + previous_plan_id, + quantity, + previous_quantity, + new_quantity, + seat_delta, + first_purchase, + guest_checkout, + onboarding, + cancel_at_period_end, + fully_refunded, + ended_at, + trial_end_at, + amount_due_minor, + attempt_count, + experiment_id, + experiment_variant, + assignment_version, + delivery_loss_count, + events, + uniqExactMerge(actors) AS actors, + uniqExactMerge(users) AS users, + uniqExactMerge(organizations) AS organizations, + revenue_minor + FROM product_events_daily_exact + WHERE copy_run_id = '' + AND {% if defined(synthetic_run_id) %} + synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + traffic_class = 'external' + AND synthetic_run_id = '' + {% end %} + AND date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(event_name) %} AND event_name = {{String(event_name)}} {% end %} + {% if defined(schema_version) %} AND schema_version = {{UInt16(schema_version)}} {% end %} + {% if defined(source) %} AND source = {{String(source)}} {% end %} + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + {% if defined(hostname) %} AND hostname = lower({{String(hostname)}}) {% end %} + {% if defined(country) %} AND country = upper({{String(country)}}) {% end %} + {% if defined(device) %} AND device = {{String(device)}} {% end %} + {% if defined(browser) %} AND browser = {{String(browser)}} {% end %} + {% if defined(os) %} AND os = {{String(os)}} {% end %} + {% if defined(channel) %} AND channel = {{String(channel)}} {% end %} + {% if defined(plan_id) %} AND plan_id = {{String(plan_id)}} {% end %} + {% if defined(recording_status) %} + AND recording_status = {{String(recording_status)}} + AND throwIf({{String(recording_status)}} NOT IN ('success', 'degraded', 'failed', 'unknown'), 'recording_status is invalid') = 0 + {% end %} + {% if defined(payment_status) %} AND payment_status = {{String(payment_status)}} {% end %} + {% if defined(subscription_status) %} AND subscription_status = {{String(subscription_status)}} {% end %} + {% if defined(currency) %} AND currency = upper({{String(currency)}}) {% end %} + {% if defined(change_kind) %} AND change_kind = {{String(change_kind)}} {% end %} + {% if defined(previous_status) %} AND previous_status = {{String(previous_status)}} {% end %} + {% if defined(new_status) %} AND new_status = {{String(new_status)}} {% end %} + {% if defined(first_purchase) %} AND first_purchase = {{String(first_purchase)}} {% end %} + {% if defined(guest_checkout) %} AND guest_checkout = {{String(guest_checkout)}} {% end %} + {% if defined(cancel_at_period_end) %} AND cancel_at_period_end = {{String(cancel_at_period_end)}} {% end %} + {% if defined(fully_refunded) %} AND fully_refunded = {{String(fully_refunded)}} {% end %} + {% if defined(experiment_id) %} AND experiment_id = {{String(experiment_id)}} {% end %} + {% if defined(experiment_variant) %} AND experiment_variant = {{String(experiment_variant)}} {% end %} + {% if defined(assignment_version) %} AND assignment_version = {{String(assignment_version)}} {% end %} + GROUP BY date, event_name, schema_version, source, platform, app_version, hostname, country, device, browser, os, channel, plan_id, recording_status, payment_status, subscription_status, currency, billing_interval, change_kind, previous_status, new_status, previous_plan_id, quantity, previous_quantity, new_quantity, seat_delta, first_purchase, guest_checkout, onboarding, cancel_at_period_end, fully_refunded, ended_at, trial_end_at, amount_due_minor, attempt_count, experiment_id, experiment_variant, assignment_version, delivery_loss_count, events, revenue_minor, calculated_at + ORDER BY date DESC, event_name ASC, schema_version DESC, source ASC, platform ASC, app_version ASC, hostname ASC, country ASC, device ASC, browser ASC, os ASC, channel ASC, plan_id ASC, recording_status ASC, payment_status ASC, subscription_status ASC, currency ASC, billing_interval ASC, change_kind ASC, previous_status ASC, new_status ASC, previous_plan_id ASC, experiment_id ASC, experiment_variant ASC, assignment_version ASC + LIMIT greatest(1, least({{Int32(limit, 1000)}}, 1000)) + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_events_daily_bounded_v2.pipe b/scripts/analytics/tinybird/pipes/product_events_daily_bounded_v2.pipe new file mode 100644 index 00000000000..14771905666 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_events_daily_bounded_v2.pipe @@ -0,0 +1,111 @@ +DESCRIPTION > + Build one generation of bounded hot or settled daily event decisions from canonical event states. + +TOKEN product_events_copy_runner READ + +NODE product_events_daily_bounded_v2_node +SQL > + % + {% if not defined(source_cutoff) %} + {{ error('source_cutoff is required') }} + {% end %} + {% if not defined(generation_id) %} + {{ error('generation_id is required') }} + {% end %} + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + SELECT + toDateTime64({{DateTime64(source_cutoff)}}, 3) AS source_cutoff, + {{String(generation_id)}} AS generation_id, + toUInt8(0) AS is_marker, + now64(3) AS calculated_at, + '' AS copy_run_id, + toDate(occurred_at, 'UTC') AS date, + event_name, + schema_version, + source, + platform, + app_version, + hostname, + country, + device, + browser, + os, + channel, + traffic_class, + synthetic_run_id, + if(event_name = 'subscription_changed', JSONExtractString(properties, 'new_price_id'), JSONExtractString(properties, 'price_id')) AS plan_id, + if( + event_name = 'recording_completed', + multiIf( + lower(JSONExtractString(properties, 'status')) = 'success', 'success', + lower(JSONExtractString(properties, 'status')) = 'degraded', 'degraded', + lower(JSONExtractString(properties, 'status')) = 'failed', 'failed', + 'unknown' + ), + '' + ) AS recording_status, + if(event_name = 'purchase_completed', JSONExtractString(properties, 'payment_status'), '') AS payment_status, + multiIf( + event_name IN ('purchase_completed', 'trial_started'), JSONExtractString(properties, 'subscription_status'), + event_name = 'trial_converted', JSONExtractString(properties, 'new_status'), + event_name = 'subscription_changed', JSONExtractString(properties, 'new_status'), + event_name = 'subscription_cancelled', JSONExtractString(properties, 'status'), + '' + ) AS subscription_status, + if(event_name IN ('purchase_completed', 'trial_started', 'subscription_renewed', 'subscription_refunded', 'subscription_payment_failed'), upper(JSONExtractString(properties, 'currency')), '') AS currency, + if(event_name IN ('purchase_completed', 'trial_started'), JSONExtractString(properties, 'billing_interval'), '') AS billing_interval, + if(event_name = 'subscription_changed', JSONExtractString(properties, 'change_kind'), '') AS change_kind, + if(event_name IN ('trial_converted', 'subscription_changed'), JSONExtractString(properties, 'previous_status'), '') AS previous_status, + if(event_name IN ('trial_converted', 'subscription_changed'), JSONExtractString(properties, 'new_status'), '') AS new_status, + if(event_name = 'subscription_changed', JSONExtractString(properties, 'previous_price_id'), '') AS previous_plan_id, + if(event_name IN ('checkout_started', 'guest_checkout_started', 'purchase_completed', 'trial_started'), JSONExtractInt(properties, 'quantity'), 0) AS quantity, + if(event_name = 'subscription_changed', JSONExtractInt(properties, 'previous_quantity'), 0) AS previous_quantity, + if(event_name = 'subscription_changed', JSONExtractInt(properties, 'new_quantity'), 0) AS new_quantity, + if(event_name = 'subscription_changed' AND JSONExtractString(properties, 'change_kind') = 'seats', JSONExtractInt(properties, 'new_quantity') - JSONExtractInt(properties, 'previous_quantity'), 0) AS seat_delta, + if(event_name = 'purchase_completed', if(JSONExtractBool(properties, 'is_first_purchase'), 'true', 'false'), '') AS first_purchase, + if(event_name IN ('purchase_completed', 'trial_started'), if(JSONExtractBool(properties, 'is_guest_checkout'), 'true', 'false'), '') AS guest_checkout, + if(event_name IN ('checkout_started', 'purchase_completed', 'trial_started'), if(JSONExtractBool(properties, 'is_onboarding'), 'true', 'false'), '') AS onboarding, + if(event_name = 'subscription_cancelled', if(JSONExtractBool(properties, 'cancel_at_period_end'), 'true', 'false'), '') AS cancel_at_period_end, + if(event_name = 'subscription_refunded', if(JSONExtractBool(properties, 'fully_refunded'), 'true', 'false'), '') AS fully_refunded, + if(event_name = 'subscription_cancelled', JSONExtractInt(properties, 'ended_at'), 0) AS ended_at, + if(event_name = 'trial_started', JSONExtractInt(properties, 'trial_end_at'), 0) AS trial_end_at, + if(event_name = 'subscription_payment_failed', JSONExtractInt(properties, 'amount_due_minor'), 0) AS amount_due_minor, + if(event_name = 'subscription_payment_failed', JSONExtractInt(properties, 'attempt_count'), 0) AS attempt_count, + if(event_name = 'experiment_exposed', JSONExtractString(properties, 'experiment_id'), '') AS experiment_id, + if(event_name = 'experiment_exposed', JSONExtractString(properties, 'variant'), '') AS experiment_variant, + if(event_name = 'experiment_exposed', JSONExtractString(properties, 'assignment_version'), '') AS assignment_version, + toUInt64(sum(if(event_name = 'analytics_delivery_loss', greatest(JSONExtractInt(properties, 'count'), 0), 0))) AS delivery_loss_count, + toUInt64(count()) AS events, + uniqExactState(if(user_id != '', concat('user:', user_id), if(anonymous_id != '', concat('anonymous:', anonymous_id), concat('session:', session_id)))) AS actors, + uniqExactStateIf(user_id, user_id != '') AS users, + uniqExactStateIf(organization_id, organization_id != '') AS organizations, + toInt64(sum(multiIf( + event_name = 'purchase_completed' AND payment_status = 'paid', JSONExtractInt(properties, 'amount_total_minor'), + event_name = 'subscription_renewed', JSONExtractInt(properties, 'amount_paid_minor'), + event_name = 'subscription_refunded', -JSONExtractInt(properties, 'amount_refunded_minor'), + 0 + ))) AS revenue_minor + FROM product_events_canonical_window + WHERE toDate(occurred_at, 'UTC') >= {% if defined(settled_date) %} toDate({{Date(settled_date)}}) {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) - INTERVAL 8 DAY {% end %} + AND toDate(occurred_at, 'UTC') < {% if defined(settled_date) %} toDate({{Date(settled_date)}}) + INTERVAL 1 DAY {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) + INTERVAL 1 DAY {% end %} + GROUP BY date, event_name, schema_version, source, platform, app_version, hostname, country, device, browser, os, channel, traffic_class, synthetic_run_id, plan_id, recording_status, payment_status, subscription_status, currency, billing_interval, change_kind, previous_status, new_status, previous_plan_id, quantity, previous_quantity, new_quantity, seat_delta, first_purchase, guest_checkout, onboarding, cancel_at_period_end, fully_refunded, ended_at, trial_end_at, amount_due_minor, attempt_count, experiment_id, experiment_variant, assignment_version + UNION ALL + SELECT + toDateTime64({{DateTime64(source_cutoff)}}, 3), + {{String(generation_id)}}, + toUInt8(1), + now64(3), + {% if defined(copy_run_id) %} {{String(copy_run_id)}} {% else %} '' {% end %}, + {% if defined(settled_date) %} toDate({{Date(settled_date)}}) {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) {% end %}, + '', toUInt16(0), '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', + toInt64(0), toInt64(0), toInt64(0), toInt64(0), + '', '', '', '', '', + toInt64(0), toInt64(0), toInt64(0), toInt64(0), + '', '', '', + toUInt64(0), toUInt64(0), + uniqExactStateIf('', 0), uniqExactStateIf('', 0), uniqExactStateIf('', 0), + toInt64(0) + WHERE throwIf(length({{String(generation_id)}}) < 8 OR length({{String(generation_id)}}) > 128, 'generation_id has an invalid length') = 0 + {% if defined(settled_date) %} AND throwIf(toDate({{Date(settled_date)}}) > toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) - INTERVAL 8 DAY, 'settled_date is inside the mutable window') = 0 {% end %} diff --git a/scripts/analytics/tinybird/pipes/product_events_daily_current_v2.pipe b/scripts/analytics/tinybird/pipes/product_events_daily_current_v2.pipe new file mode 100644 index 00000000000..16cb3769293 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_events_daily_current_v2.pipe @@ -0,0 +1,39 @@ +DESCRIPTION > + Expose the active bounded event-decision generation and latest settled UTC partitions. + +NODE product_events_daily_latest_hot_generation_v2_node +SQL > + SELECT + argMax(generations.generation_id, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS generation_id, + argMax(generations.source_cutoff, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS source_cutoff + FROM product_analytics_generations_v2 AS generations + WHERE generations.generation_kind = 'hot' + +NODE product_events_daily_latest_cold_generations_v2_node +SQL > + SELECT + generations.settled_date AS date, + argMax(generations.generation_id, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS generation_id, + argMax(generations.source_cutoff, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS source_cutoff + FROM product_analytics_generations_v2 AS generations + WHERE generations.generation_kind = 'cold' + GROUP BY generations.settled_date + +NODE product_events_daily_current_v2_node +SQL > + SELECT hot.* + FROM product_events_daily_hot_v2 AS hot + INNER JOIN product_events_daily_latest_hot_generation_v2_node AS generation + ON hot.generation_id = generation.generation_id + AND hot.source_cutoff = generation.source_cutoff + WHERE hot.is_marker = 0 + UNION ALL + SELECT cold.* + FROM product_events_daily_cold_v2 AS cold + INNER JOIN product_events_daily_latest_cold_generations_v2_node AS generation + ON cold.date = generation.date + AND cold.generation_id = generation.generation_id + AND cold.source_cutoff = generation.source_cutoff + CROSS JOIN product_events_daily_latest_hot_generation_v2_node AS hot_generation + WHERE cold.is_marker = 0 + AND cold.date < toDate(hot_generation.source_cutoff, 'UTC') - INTERVAL 8 DAY diff --git a/scripts/analytics/tinybird/pipes/product_events_health.pipe b/scripts/analytics/tinybird/pipes/product_events_health.pipe new file mode 100644 index 00000000000..bd2daa73615 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_events_health.pipe @@ -0,0 +1,33 @@ +DESCRIPTION > + Inspect hourly product event delivery quality over a maximum 31-day window. + + +NODE product_events_health_node +SQL > + % + {% if not defined(start_time) %} + {{ error('start_time is required') }} + {% end %} + {% if not defined(end_time) %} + {{ error('end_time is required') }} + {% end %} + SELECT + sum(received_rows) AS received_rows, + uniqExactMerge(unique_events) AS unique_events, + uniqExactMerge(unique_payloads) AS unique_payloads, + received_rows - unique_payloads AS duplicate_rows, + unique_payloads - unique_events AS payload_conflicts, + sum(future_events) AS future_events, + sum(late_events) AS late_events, + sum(missing_identity_events) AS missing_identity_events, + quantilesTDigestMerge(0.5, 0.95, 0.99)(ingestion_lag_ms) AS ingestion_lag_ms + FROM product_events_health_hourly_exact + WHERE copy_run_id = '' + AND hour >= toStartOfHour(toDateTime64({{DateTime64(start_time)}}, 3)) + AND hour <= toDateTime64({{DateTime64(end_time)}}, 3) + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + AND throwIf(toDateTime64({{DateTime64(end_time)}}, 3) < toDateTime64({{DateTime64(start_time)}}, 3), 'end_time must not precede start_time') = 0 + AND throwIf(toDateTime64({{DateTime64(end_time)}}, 3) > toDateTime64({{DateTime64(start_time)}}, 3) + INTERVAL 31 DAY, 'health windows cannot exceed 31 days') = 0 + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_experiment_outcomes.pipe b/scripts/analytics/tinybird/pipes/product_experiment_outcomes.pipe new file mode 100644 index 00000000000..1501a8256d7 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_experiment_outcomes.pipe @@ -0,0 +1,41 @@ +DESCRIPTION > + Query aggregate 30-day first authoritative outcomes for actors with an explicit stable experiment exposure. + + +NODE product_experiment_outcomes_node +SQL > + % + SELECT + outcomes.experiment_id, + outcomes.assignment_version, + outcomes.variant, + outcomes.platform, + outcomes.app_version, + outcomes.outcome_name, + toUInt64(sum(outcomes.exposed_actors)) AS exposed_actors, + toUInt64(sum(outcomes.converted_actors)) AS converted_actors, + round(if(sum(outcomes.exposed_actors) = 0, 0, 100 * sum(outcomes.converted_actors) / sum(outcomes.exposed_actors)), 2) AS conversion_rate + FROM product_experiment_outcomes_exact AS outcomes + WHERE outcomes.copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND cohort_date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND cohort_date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(experiment_id) %} AND experiment_id = {{String(experiment_id)}} {% end %} + {% if defined(assignment_version) %} AND assignment_version = {{String(assignment_version)}} {% end %} + {% if defined(variant) %} AND variant = {{String(variant)}} {% end %} + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + {% if defined(outcome_name) %} + AND outcome_name = {{String(outcome_name)}} + AND throwIf(NOT has(['signup', 'share_created', 'paid_purchase'], {{String(outcome_name)}}), 'outcome_name is invalid') = 0 + {% end %} + GROUP BY outcomes.experiment_id, outcomes.assignment_version, outcomes.variant, outcomes.platform, outcomes.app_version, outcomes.outcome_name + ORDER BY outcomes.experiment_id ASC, outcomes.assignment_version ASC, outcomes.variant ASC, outcomes.outcome_name ASC, outcomes.platform ASC, outcomes.app_version ASC + LIMIT greatest(1, least({{Int32(limit, 1000)}}, 1000)) + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_feature_adoption.pipe b/scripts/analytics/tinybird/pipes/product_feature_adoption.pipe new file mode 100644 index 00000000000..9962d31e55c --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_feature_adoption.pipe @@ -0,0 +1,54 @@ +DESCRIPTION > + Query feature event volume and daily unique adopters after applying decision filters. + + +NODE product_feature_adoption_node +SQL > + % + WITH daily AS ( + SELECT + date, + event_name, + toUInt64(sum(events)) AS events, + uniqExactMerge(actors) AS actors, + uniqExactMerge(users) AS users, + uniqExactMerge(organizations) AS organizations + FROM product_events_daily_exact + WHERE {% if defined(synthetic_run_id) %} + synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + traffic_class = 'external' + AND synthetic_run_id = '' + {% end %} + AND date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(event_name) %} AND event_name = {{String(event_name)}} {% end %} + {% if defined(source) %} AND source = {{String(source)}} {% end %} + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + {% if defined(hostname) %} AND hostname = lower({{String(hostname)}}) {% end %} + {% if defined(country) %} AND country = upper({{String(country)}}) {% end %} + {% if defined(device) %} AND device = {{String(device)}} {% end %} + {% if defined(browser) %} AND browser = {{String(browser)}} {% end %} + {% if defined(os) %} AND os = {{String(os)}} {% end %} + {% if defined(channel) %} AND channel = {{String(channel)}} {% end %} + {% if defined(plan_id) %} AND plan_id = {{String(plan_id)}} {% end %} + {% if defined(payment_status) %} AND payment_status = {{String(payment_status)}} {% end %} + {% if defined(subscription_status) %} AND subscription_status = {{String(subscription_status)}} {% end %} + {% if defined(currency) %} AND currency = upper({{String(currency)}}) {% end %} + {% if defined(change_kind) %} AND change_kind = {{String(change_kind)}} {% end %} + GROUP BY date, event_name + ) + SELECT + event_name, + toUInt64(sum(events)) AS events, + toUInt64(sum(actors)) AS actor_days, + toUInt64(sum(users)) AS user_days, + toUInt64(sum(organizations)) AS organization_days + FROM daily + GROUP BY event_name + ORDER BY events DESC, event_name ASC + LIMIT 200 + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_identity_funnel.pipe b/scripts/analytics/tinybird/pipes/product_identity_funnel.pipe new file mode 100644 index 00000000000..2eaf05f4610 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_identity_funnel.pipe @@ -0,0 +1,57 @@ +DESCRIPTION > + Query complete privacy-safe anonymous acquisition to authenticated conversion totals without raw identifiers. + + +NODE product_identity_funnel_node +SQL > + % + WITH totals AS ( + SELECT + sum(linked_visitors) AS linked_visitors, + sum(linked_users) AS linked_users, + sum(signup_users) AS signup_users, + sum(organizations) AS organizations, + sum(guest_checkout_visitors) AS guest_checkout_visitors, + sum(guest_purchasers) AS guest_purchasers, + sum(authenticated_checkout_users) AS authenticated_checkout_users, + sum(web_checkout_users) AS web_checkout_users, + sum(desktop_checkout_users) AS desktop_checkout_users, + sum(mobile_checkout_users) AS mobile_checkout_users, + sum(cross_device_checkout_users) AS cross_device_checkout_users, + sum(trial_users) AS trial_users, + sum(purchasers) AS purchasers + FROM product_identity_funnel_exact + WHERE copy_run_id = '' + AND {% if defined(synthetic_run_id) %} + synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + synthetic_run_id = '' + {% end %} + AND cohort_date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND cohort_date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(channel) %} AND channel = {{String(channel)}} {% end %} + {% if defined(source) %} AND attribution_source = {{String(source)}} {% end %} + {% if defined(medium) %} AND attribution_medium = {{String(medium)}} {% end %} + {% if defined(campaign) %} AND campaign = {{String(campaign)}} {% end %} + {% if defined(country) %} AND country = upper({{String(country)}}) {% end %} + ) + SELECT + toUInt64(linked_visitors) AS linked_visitors, + toUInt64(linked_users) AS linked_users, + toUInt64(signup_users) AS signup_users, + toUInt64(organizations) AS organizations, + toUInt64(guest_checkout_visitors) AS guest_checkout_visitors, + toUInt64(guest_purchasers) AS guest_purchasers, + toUInt64(authenticated_checkout_users) AS authenticated_checkout_users, + toUInt64(web_checkout_users) AS web_checkout_users, + toUInt64(desktop_checkout_users) AS desktop_checkout_users, + toUInt64(mobile_checkout_users) AS mobile_checkout_users, + toUInt64(cross_device_checkout_users) AS cross_device_checkout_users, + toUInt64(trial_users) AS trial_users, + toUInt64(purchasers) AS purchasers, + round(if(linked_users = 0, 0, signup_users * 100.0 / linked_users), 2) AS signup_rate, + round(if(linked_users = 0, 0, purchasers * 100.0 / linked_users), 2) AS purchase_rate + FROM totals + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_traffic_countries.pipe b/scripts/analytics/tinybird/pipes/product_traffic_countries.pipe new file mode 100644 index 00000000000..01f9677559c --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_traffic_countries.pipe @@ -0,0 +1,30 @@ +DESCRIPTION > + Query country traffic without raw identifiers or raw network addresses. + + +NODE product_traffic_countries_node +SQL > + % + SELECT + country, + uniqExactMerge(visitors) AS visitors, + sum(visits) AS visits, + sum(pageviews) AS pageviews + FROM product_traffic_daily_exact + WHERE copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + {% if defined(hostname) %} AND hostname = lower({{String(hostname)}}) {% end %} + GROUP BY country + ORDER BY visits DESC, country ASC + LIMIT 300 + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_traffic_daily_bounded_v2.pipe b/scripts/analytics/tinybird/pipes/product_traffic_daily_bounded_v2.pipe new file mode 100644 index 00000000000..466213e3213 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_traffic_daily_bounded_v2.pipe @@ -0,0 +1,97 @@ +DESCRIPTION > + Build one generation of bounded hot or settled 30-minute traffic-session decisions. + +TOKEN product_events_copy_runner READ + +NODE product_traffic_daily_bounded_v2_node +SQL > + % + {% if not defined(source_cutoff) %} + {{ error('source_cutoff is required') }} + {% end %} + {% if not defined(generation_id) %} + {{ error('generation_id is required') }} + {% end %} + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + WITH session_engagement AS ( + SELECT + synthetic_run_id, + session_id, + toUInt64(sum(greatest(0, JSONExtractInt(properties, 'engaged_ms')))) AS session_engaged_ms, + max(greatest(0, least(100, JSONExtractInt(properties, 'max_scroll_depth')))) AS session_max_scroll_depth + FROM product_events_canonical_window + WHERE event_name = 'page_engagement' + AND (traffic_class = 'external' OR synthetic_run_id != '') + AND session_id != '' + GROUP BY synthetic_run_id, session_id + ), sessions AS ( + SELECT + synthetic_run_id, + session_id, + concat('anonymous:', argMin(anonymous_id, tuple(occurred_at, event_id))) AS visitor_id, + min(occurred_at) AS started_at, + argMin(platform, tuple(occurred_at, event_id)) AS platform, + argMin(app_version, tuple(occurred_at, event_id)) AS app_version, + argMin(hostname, tuple(occurred_at, event_id)) AS hostname, + argMin(country, tuple(occurred_at, event_id)) AS country, + argMin(device, tuple(occurred_at, event_id)) AS device, + argMin(browser, tuple(occurred_at, event_id)) AS browser, + argMin(os, tuple(occurred_at, event_id)) AS os, + argMin(channel, tuple(occurred_at, event_id)) AS channel, + argMin(JSONExtractString(properties, 'session_touch_source'), tuple(occurred_at, event_id)) AS attribution_source, + argMin(JSONExtractString(properties, 'session_touch_medium'), tuple(occurred_at, event_id)) AS attribution_medium, + argMin(JSONExtractString(properties, 'session_touch_campaign'), tuple(occurred_at, event_id)) AS campaign, + toUInt64(count()) AS session_pageviews + FROM product_events_canonical_window + WHERE event_name = 'page_view' + AND (traffic_class = 'external' OR synthetic_run_id != '') + AND session_id != '' + AND anonymous_id != '' + GROUP BY synthetic_run_id, session_id + ) + SELECT + toDateTime64({{DateTime64(source_cutoff)}}, 3) AS source_cutoff, + {{String(generation_id)}} AS generation_id, + toUInt8(0) AS is_marker, + now64(3) AS calculated_at, + '' AS copy_run_id, + synthetic_run_id, + toDate(started_at, 'UTC') AS date, + platform, + app_version, + hostname, + country, + device, + browser, + os, + channel, + attribution_source, + attribution_medium, + campaign, + uniqExactState(visitor_id) AS visitors, + toUInt64(count()) AS visits, + toUInt64(sum(session_pageviews)) AS pageviews, + toUInt64(sum(if(session_pageviews = 1 AND coalesce(session_engaged_ms, 0) < 10000 AND coalesce(session_max_scroll_depth, 0) < 50, 1, 0))) AS bounces, + toUInt64(sum(coalesce(session_engaged_ms, 0))) AS visit_duration_ms, + toUInt64(sum(coalesce(session_engaged_ms, 0))) AS engaged_ms + FROM sessions + LEFT JOIN session_engagement USING (synthetic_run_id, session_id) + GROUP BY synthetic_run_id, date, platform, app_version, hostname, country, device, browser, os, channel, attribution_source, attribution_medium, campaign + HAVING date >= {% if defined(settled_date) %} toDate({{Date(settled_date)}}) {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) - INTERVAL 8 DAY {% end %} + AND date < {% if defined(settled_date) %} toDate({{Date(settled_date)}}) + INTERVAL 1 DAY {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) + INTERVAL 1 DAY {% end %} + UNION ALL + SELECT + toDateTime64({{DateTime64(source_cutoff)}}, 3), + {{String(generation_id)}}, + toUInt8(1), + now64(3), + {% if defined(copy_run_id) %} {{String(copy_run_id)}} {% else %} '' {% end %}, + '', + {% if defined(settled_date) %} toDate({{Date(settled_date)}}) {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) {% end %}, + '', '', '', '', '', '', '', '', '', '', '', + uniqExactStateIf('', 0), + toUInt64(0), toUInt64(0), toUInt64(0), toUInt64(0), toUInt64(0) + WHERE throwIf(length({{String(generation_id)}}) < 8 OR length({{String(generation_id)}}) > 128, 'generation_id has an invalid length') = 0 + {% if defined(settled_date) %} AND throwIf(toDate({{Date(settled_date)}}) > toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) - INTERVAL 8 DAY, 'settled_date is inside the mutable window') = 0 {% end %} diff --git a/scripts/analytics/tinybird/pipes/product_traffic_daily_current_v2.pipe b/scripts/analytics/tinybird/pipes/product_traffic_daily_current_v2.pipe new file mode 100644 index 00000000000..6ec17c7d1a3 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_traffic_daily_current_v2.pipe @@ -0,0 +1,39 @@ +DESCRIPTION > + Expose the active bounded traffic generation and latest settled UTC partitions. + +NODE product_traffic_daily_latest_hot_generation_v2_node +SQL > + SELECT + argMax(generations.generation_id, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS generation_id, + argMax(generations.source_cutoff, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS source_cutoff + FROM product_analytics_generations_v2 AS generations + WHERE generations.generation_kind = 'hot' + +NODE product_traffic_daily_latest_cold_generations_v2_node +SQL > + SELECT + generations.settled_date AS date, + argMax(generations.generation_id, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS generation_id, + argMax(generations.source_cutoff, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS source_cutoff + FROM product_analytics_generations_v2 AS generations + WHERE generations.generation_kind = 'cold' + GROUP BY generations.settled_date + +NODE product_traffic_daily_current_v2_node +SQL > + SELECT hot.* + FROM product_traffic_daily_hot_v2 AS hot + INNER JOIN product_traffic_daily_latest_hot_generation_v2_node AS generation + ON hot.generation_id = generation.generation_id + AND hot.source_cutoff = generation.source_cutoff + WHERE hot.is_marker = 0 + UNION ALL + SELECT cold.* + FROM product_traffic_daily_cold_v2 AS cold + INNER JOIN product_traffic_daily_latest_cold_generations_v2_node AS generation + ON cold.date = generation.date + AND cold.generation_id = generation.generation_id + AND cold.source_cutoff = generation.source_cutoff + CROSS JOIN product_traffic_daily_latest_hot_generation_v2_node AS hot_generation + WHERE cold.is_marker = 0 + AND cold.date < toDate(hot_generation.source_cutoff, 'UTC') - INTERVAL 8 DAY diff --git a/scripts/analytics/tinybird/pipes/product_traffic_overview.pipe b/scripts/analytics/tinybird/pipes/product_traffic_overview.pipe new file mode 100644 index 00000000000..5697b154205 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_traffic_overview.pipe @@ -0,0 +1,50 @@ +DESCRIPTION > + Query exact daily Plausible-style traffic metrics from privacy-safe aggregates. + + +NODE product_traffic_overview_node +SQL > + % + WITH daily AS ( + SELECT + date, + uniqExactMerge(visitors) AS visitor_count, + sum(visits) AS visit_count, + sum(pageviews) AS pageview_count, + sum(bounces) AS bounce_count, + sum(visit_duration_ms) AS duration_total_ms, + sum(engaged_ms) AS engagement_total_ms + FROM product_traffic_daily_exact + WHERE copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + {% if defined(hostname) %} AND hostname = lower({{String(hostname)}}) {% end %} + {% if defined(country) %} AND country = upper({{String(country)}}) {% end %} + {% if defined(device) %} AND device = {{String(device)}} {% end %} + {% if defined(browser) %} AND browser = {{String(browser)}} {% end %} + {% if defined(os) %} AND os = {{String(os)}} {% end %} + {% if defined(channel) %} AND channel = {{String(channel)}} {% end %} + GROUP BY date + ) + SELECT + date, + visitor_count AS visitors, + visit_count AS visits, + pageview_count AS pageviews, + round(if(visit_count = 0, 0, pageview_count / visit_count), 2) AS views_per_visit, + round(if(visit_count = 0, 0, 100 * bounce_count / visit_count), 2) AS bounce_rate, + toUInt64(if(visit_count = 0, 0, duration_total_ms / visit_count)) AS visit_duration_ms, + engagement_total_ms AS engaged_ms + FROM daily + ORDER BY date ASC + LIMIT 800 + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_traffic_pages.pipe b/scripts/analytics/tinybird/pipes/product_traffic_pages.pipe new file mode 100644 index 00000000000..9abbfb154cb --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_traffic_pages.pipe @@ -0,0 +1,51 @@ +DESCRIPTION > + Query top, landing, exit, and engagement page metrics without raw identifiers. + + +NODE product_traffic_pages_node +SQL > + % + WITH totals AS ( + SELECT + pathname, + uniqExactMerge(visitors) AS visitor_count, + uniqExactMerge(visits) AS visit_count, + sum(pageviews) AS pageview_count, + sum(landings) AS landing_count, + sum(exits) AS exit_count, + sum(engaged_ms) AS engagement_total_ms, + sum(scroll_depth_sum) AS scroll_depth_total + FROM product_traffic_pages_daily_exact + WHERE copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + {% if defined(hostname) %} AND hostname = lower({{String(hostname)}}) {% end %} + {% if defined(country) %} AND country = upper({{String(country)}}) {% end %} + {% if defined(device) %} AND device = {{String(device)}} {% end %} + {% if defined(browser) %} AND browser = {{String(browser)}} {% end %} + {% if defined(os) %} AND os = {{String(os)}} {% end %} + {% if defined(channel) %} AND channel = {{String(channel)}} {% end %} + GROUP BY pathname + ) + SELECT + pathname, + visitor_count AS visitors, + visit_count AS visits, + pageview_count AS pageviews, + landing_count AS landings, + exit_count AS exits, + toUInt64(if(pageview_count = 0, 0, engagement_total_ms / pageview_count)) AS time_on_page_ms, + round(if(pageview_count = 0, 0, scroll_depth_total / pageview_count), 2) AS average_scroll_depth + FROM totals + ORDER BY pageviews DESC, pathname ASC + LIMIT greatest(1, least({{Int32(limit, 100)}}, 500)) + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_traffic_pages_daily_bounded_v2.pipe b/scripts/analytics/tinybird/pipes/product_traffic_pages_daily_bounded_v2.pipe new file mode 100644 index 00000000000..d0be5cb693f --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_traffic_pages_daily_bounded_v2.pipe @@ -0,0 +1,104 @@ +DESCRIPTION > + Build one generation of bounded hot or settled page and engagement decisions. + +TOKEN product_events_copy_runner READ + +NODE product_traffic_pages_daily_bounded_v2_node +SQL > + % + {% if not defined(source_cutoff) %} + {{ error('source_cutoff is required') }} + {% end %} + {% if not defined(generation_id) %} + {{ error('generation_id is required') }} + {% end %} + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + WITH sessions AS ( + SELECT + synthetic_run_id, + session_id, + argMin(event_id, tuple(occurred_at, event_id)) AS landing_event_id, + argMax(event_id, tuple(occurred_at, event_id)) AS exit_event_id + FROM product_events_canonical_window + WHERE event_name = 'page_view' + AND (traffic_class = 'external' OR synthetic_run_id != '') + AND session_id != '' + GROUP BY synthetic_run_id, session_id + ), engagements AS ( + SELECT + synthetic_run_id, + JSONExtractString(properties, 'page_view_id') AS page_view_id, + toUInt64(sum(greatest(0, JSONExtractInt(properties, 'engaged_ms')))) AS engaged_ms, + toFloat64(max(greatest(0, least(100, JSONExtractInt(properties, 'max_scroll_depth'))))) AS scroll_depth + FROM product_events_canonical_window + WHERE event_name = 'page_engagement' + AND (traffic_class = 'external' OR synthetic_run_id != '') + GROUP BY synthetic_run_id, page_view_id + ), page_views AS ( + SELECT + synthetic_run_id, + event_id, + occurred_at, + session_id, + concat('anonymous:', anonymous_id) AS visitor_id, + platform, + app_version, + hostname, + pathname, + country, + device, + browser, + os, + channel + FROM product_events_canonical_window + WHERE event_name = 'page_view' + AND (traffic_class = 'external' OR synthetic_run_id != '') + AND session_id != '' + AND anonymous_id != '' + ) + SELECT + toDateTime64({{DateTime64(source_cutoff)}}, 3) AS source_cutoff, + {{String(generation_id)}} AS generation_id, + toUInt8(0) AS is_marker, + now64(3) AS calculated_at, + '' AS copy_run_id, + page_views.synthetic_run_id AS synthetic_run_id, + toDate(occurred_at, 'UTC') AS date, + platform, + app_version, + hostname, + pathname, + country, + device, + browser, + os, + channel, + uniqExactState(visitor_id) AS visitors, + uniqExactState(page_views.session_id) AS visits, + toUInt64(count()) AS pageviews, + toUInt64(countIf(event_id = landing_event_id)) AS landings, + toUInt64(countIf(event_id = exit_event_id)) AS exits, + toUInt64(sum(coalesce(engaged_ms, 0))) AS engaged_ms, + toFloat64(sum(coalesce(scroll_depth, 0))) AS scroll_depth_sum + FROM page_views + INNER JOIN sessions ON page_views.synthetic_run_id = sessions.synthetic_run_id AND page_views.session_id = sessions.session_id + LEFT JOIN engagements ON page_views.synthetic_run_id = engagements.synthetic_run_id AND page_views.event_id = engagements.page_view_id + GROUP BY page_views.synthetic_run_id, date, platform, app_version, hostname, pathname, country, device, browser, os, channel + HAVING date >= {% if defined(settled_date) %} toDate({{Date(settled_date)}}) {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) - INTERVAL 8 DAY {% end %} + AND date < {% if defined(settled_date) %} toDate({{Date(settled_date)}}) + INTERVAL 1 DAY {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) + INTERVAL 1 DAY {% end %} + UNION ALL + SELECT + toDateTime64({{DateTime64(source_cutoff)}}, 3), + {{String(generation_id)}}, + toUInt8(1), + now64(3), + {% if defined(copy_run_id) %} {{String(copy_run_id)}} {% else %} '' {% end %}, + '', + {% if defined(settled_date) %} toDate({{Date(settled_date)}}) {% else %} toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) {% end %}, + '', '', '', '', '', '', '', '', '', + uniqExactStateIf('', 0), uniqExactStateIf('', 0), + toUInt64(0), toUInt64(0), toUInt64(0), toUInt64(0), toFloat64(0) + WHERE throwIf(length({{String(generation_id)}}) < 8 OR length({{String(generation_id)}}) > 128, 'generation_id has an invalid length') = 0 + {% if defined(settled_date) %} AND throwIf(toDate({{Date(settled_date)}}) > toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) - INTERVAL 8 DAY, 'settled_date is inside the mutable window') = 0 {% end %} diff --git a/scripts/analytics/tinybird/pipes/product_traffic_pages_daily_current_v2.pipe b/scripts/analytics/tinybird/pipes/product_traffic_pages_daily_current_v2.pipe new file mode 100644 index 00000000000..948a3ee2af7 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_traffic_pages_daily_current_v2.pipe @@ -0,0 +1,39 @@ +DESCRIPTION > + Expose the active bounded page generation and latest settled UTC partitions. + +NODE product_traffic_pages_daily_latest_hot_generation_v2_node +SQL > + SELECT + argMax(generations.generation_id, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS generation_id, + argMax(generations.source_cutoff, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS source_cutoff + FROM product_analytics_generations_v2 AS generations + WHERE generations.generation_kind = 'hot' + +NODE product_traffic_pages_daily_latest_cold_generations_v2_node +SQL > + SELECT + generations.settled_date AS date, + argMax(generations.generation_id, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS generation_id, + argMax(generations.source_cutoff, tuple(generations.source_cutoff, generations.published_at, generations.generation_id)) AS source_cutoff + FROM product_analytics_generations_v2 AS generations + WHERE generations.generation_kind = 'cold' + GROUP BY generations.settled_date + +NODE product_traffic_pages_daily_current_v2_node +SQL > + SELECT hot.* + FROM product_traffic_pages_daily_hot_v2 AS hot + INNER JOIN product_traffic_pages_daily_latest_hot_generation_v2_node AS generation + ON hot.generation_id = generation.generation_id + AND hot.source_cutoff = generation.source_cutoff + WHERE hot.is_marker = 0 + UNION ALL + SELECT cold.* + FROM product_traffic_pages_daily_cold_v2 AS cold + INNER JOIN product_traffic_pages_daily_latest_cold_generations_v2_node AS generation + ON cold.date = generation.date + AND cold.generation_id = generation.generation_id + AND cold.source_cutoff = generation.source_cutoff + CROSS JOIN product_traffic_pages_daily_latest_hot_generation_v2_node AS hot_generation + WHERE cold.is_marker = 0 + AND cold.date < toDate(hot_generation.source_cutoff, 'UTC') - INTERVAL 8 DAY diff --git a/scripts/analytics/tinybird/pipes/product_traffic_sources.pipe b/scripts/analytics/tinybird/pipes/product_traffic_sources.pipe new file mode 100644 index 00000000000..b7fb0b81abf --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_traffic_sources.pipe @@ -0,0 +1,46 @@ +DESCRIPTION > + Query source, channel, medium, and campaign traffic without raw identifiers. + + +NODE product_traffic_sources_node +SQL > + % + WITH totals AS ( + SELECT + channel, + attribution_source, + attribution_medium, + campaign, + uniqExactMerge(visitors) AS visitor_count, + sum(visits) AS visit_count, + sum(pageviews) AS pageview_count, + sum(bounces) AS bounce_count + FROM product_traffic_daily_exact + WHERE copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + {% if defined(hostname) %} AND hostname = lower({{String(hostname)}}) {% end %} + GROUP BY channel, attribution_source, attribution_medium, campaign + ) + SELECT + channel, + attribution_source AS source, + attribution_medium AS medium, + campaign, + visitor_count AS visitors, + visit_count AS visits, + pageview_count AS pageviews, + round(if(visit_count = 0, 0, 100 * bounce_count / visit_count), 2) AS bounce_rate + FROM totals + ORDER BY visits DESC, channel ASC, source ASC, campaign ASC + LIMIT greatest(1, least({{Int32(limit, 100)}}, 500)) + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_traffic_technology.pipe b/scripts/analytics/tinybird/pipes/product_traffic_technology.pipe new file mode 100644 index 00000000000..7b209ee822b --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_traffic_technology.pipe @@ -0,0 +1,33 @@ +DESCRIPTION > + Query device, browser, and operating-system traffic without raw user agents. + + +NODE product_traffic_technology_node +SQL > + % + SELECT + device, + browser, + os, + uniqExactMerge(visitors) AS visitors, + sum(visits) AS visits, + sum(pageviews) AS pageviews + FROM product_traffic_daily_exact + WHERE copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + {% if defined(hostname) %} AND hostname = lower({{String(hostname)}}) {% end %} + {% if defined(country) %} AND country = upper({{String(country)}}) {% end %} + GROUP BY device, browser, os + ORDER BY visits DESC, device ASC, browser ASC, os ASC + LIMIT 500 + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/product_traffic_totals.pipe b/scripts/analytics/tinybird/pipes/product_traffic_totals.pipe new file mode 100644 index 00000000000..d961e16cfec --- /dev/null +++ b/scripts/analytics/tinybird/pipes/product_traffic_totals.pipe @@ -0,0 +1,36 @@ +DESCRIPTION > + Query selected-range unique visitors and exact traffic totals by merging privacy-safe aggregate states across UTC dates. + + +NODE product_traffic_totals_node +SQL > + % + SELECT + uniqExactMerge(traffic.visitors) AS visitors, + toUInt64(sum(traffic.visits)) AS visits, + toUInt64(sum(traffic.pageviews)) AS pageviews, + round(if(sum(traffic.visits) = 0, 0, sum(traffic.pageviews) / sum(traffic.visits)), 2) AS views_per_visit, + round(if(sum(traffic.visits) = 0, 0, 100 * sum(traffic.bounces) / sum(traffic.visits)), 2) AS bounce_rate, + toUInt64(if(sum(traffic.visits) = 0, 0, sum(traffic.visit_duration_ms) / sum(traffic.visits))) AS visit_duration_ms, + toUInt64(sum(traffic.engaged_ms)) AS engaged_ms + FROM product_traffic_daily_exact AS traffic + WHERE traffic.copy_run_id = '' + {% if defined(synthetic_run_id) %} + AND synthetic_run_id = {{String(synthetic_run_id)}} + AND throwIf(length({{String(synthetic_run_id)}}) < 8 OR length({{String(synthetic_run_id)}}) > 128, 'synthetic_run_id has an invalid length') = 0 + {% else %} + AND synthetic_run_id = '' + {% end %} + AND date >= {% if defined(start_date) %} {{Date(start_date)}} {% else %} today() - INTERVAL 30 DAY {% end %} + AND date <= {% if defined(end_date) %} {{Date(end_date)}} {% else %} today() {% end %} + {% if defined(platform) %} AND platform = {{String(platform)}} {% end %} + {% if defined(app_version) %} AND app_version = {{String(app_version)}} {% end %} + {% if defined(hostname) %} AND hostname = lower({{String(hostname)}}) {% end %} + {% if defined(country) %} AND country = upper({{String(country)}}) {% end %} + {% if defined(device) %} AND device = {{String(device)}} {% end %} + {% if defined(browser) %} AND browser = {{String(browser)}} {% end %} + {% if defined(os) %} AND os = {{String(os)}} {% end %} + {% if defined(channel) %} AND channel = {{String(channel)}} {% end %} + {% if defined(source) %} AND attribution_source = {{String(source)}} {% end %} + +TYPE ENDPOINT diff --git a/scripts/analytics/tinybird/pipes/publish_product_analytics_generation_v2.pipe b/scripts/analytics/tinybird/pipes/publish_product_analytics_generation_v2.pipe new file mode 100644 index 00000000000..0a46ea1639a --- /dev/null +++ b/scripts/analytics/tinybird/pipes/publish_product_analytics_generation_v2.pipe @@ -0,0 +1,43 @@ +DESCRIPTION > + Publish one generation only after every bounded decision model has completed. + +TOKEN product_events_copy_runner READ + +NODE publish_product_analytics_generation_v2_node +SQL > + % + {% if not defined(source_cutoff) %} + {{ error('source_cutoff is required') }} + {% end %} + {% if not defined(generation_id) %} + {{ error('generation_id is required') }} + {% end %} + {% if not defined(generation_kind) %} + {{ error('generation_kind is required') }} + {% end %} + SELECT + {{String(generation_id)}} AS generation_id, + toDateTime64({{DateTime64(source_cutoff)}}, 3) AS source_cutoff, + {{String(generation_kind)}} AS generation_kind, + {% if defined(settled_date) %} toDate({{Date(settled_date)}}) {% else %} toDate('1970-01-01') {% end %} AS settled_date, + now64(3) AS published_at, + {% if defined(copy_run_id) %} {{String(copy_run_id)}} {% else %} '' {% end %} AS copy_run_id + WHERE throwIf(length({{String(generation_id)}}) < 8 OR length({{String(generation_id)}}) > 128, 'generation_id has an invalid length') = 0 + AND throwIf({{String(generation_kind)}} NOT IN ('hot', 'cold'), 'generation_kind is invalid') = 0 + {% if defined(settled_date) %} + AND throwIf({{String(generation_kind)}} != 'cold', 'settled_date requires a cold generation') = 0 + AND throwIf(toDate({{Date(settled_date)}}) > toDate(toDateTime64({{DateTime64(source_cutoff)}}, 3)) - INTERVAL 8 DAY, 'settled_date is inside the mutable window') = 0 + AND throwIf((SELECT count() FROM product_events_daily_cold_v2 WHERE generation_id = {{String(generation_id)}} AND source_cutoff = toDateTime64({{DateTime64(source_cutoff)}}, 3) AND date = toDate({{Date(settled_date)}}) AND is_marker = 1) = 0, 'event cold generation is incomplete') = 0 + AND throwIf((SELECT count() FROM product_traffic_daily_cold_v2 WHERE generation_id = {{String(generation_id)}} AND source_cutoff = toDateTime64({{DateTime64(source_cutoff)}}, 3) AND date = toDate({{Date(settled_date)}}) AND is_marker = 1) = 0, 'traffic cold generation is incomplete') = 0 + AND throwIf((SELECT count() FROM product_traffic_pages_daily_cold_v2 WHERE generation_id = {{String(generation_id)}} AND source_cutoff = toDateTime64({{DateTime64(source_cutoff)}}, 3) AND date = toDate({{Date(settled_date)}}) AND is_marker = 1) = 0, 'page cold generation is incomplete') = 0 + {% else %} + AND throwIf({{String(generation_kind)}} != 'hot', 'cold generation requires settled_date') = 0 + AND throwIf((SELECT count() FROM product_events_daily_hot_v2 WHERE generation_id = {{String(generation_id)}} AND source_cutoff = toDateTime64({{DateTime64(source_cutoff)}}, 3) AND is_marker = 1) = 0, 'event hot generation is incomplete') = 0 + AND throwIf((SELECT count() FROM product_traffic_daily_hot_v2 WHERE generation_id = {{String(generation_id)}} AND source_cutoff = toDateTime64({{DateTime64(source_cutoff)}}, 3) AND is_marker = 1) = 0, 'traffic hot generation is incomplete') = 0 + AND throwIf((SELECT count() FROM product_traffic_pages_daily_hot_v2 WHERE generation_id = {{String(generation_id)}} AND source_cutoff = toDateTime64({{DateTime64(source_cutoff)}}, 3) AND is_marker = 1) = 0, 'page hot generation is incomplete') = 0 + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_analytics_generations_v2 +COPY_MODE append +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_activation_daily_exact.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_activation_daily_exact.pipe new file mode 100644 index 00000000000..bb0f5042439 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_activation_daily_exact.pipe @@ -0,0 +1,58 @@ +DESCRIPTION > + Rebuild signup activation cohorts from exact canonical server facts. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_activation_daily_exact_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + WITH signups AS ( + SELECT + synthetic_run_id, + user_id, + min(occurred_at) AS signed_up_at + FROM product_events_canonical_current + WHERE event_name = 'user_signed_up' + AND user_id != '' + GROUP BY synthetic_run_id, user_id + ), activations AS ( + SELECT + synthetic_run_id, + user_id, + min(occurred_at) AS activated_at + FROM product_events_canonical_current + WHERE event_name = 'share_link_created' + AND user_id != '' + GROUP BY synthetic_run_id, user_id + ) + SELECT + now64(3) AS calculated_at, + '' AS copy_run_id, + synthetic_run_id, + toDate(signed_up_at, 'UTC') AS cohort_date, + uniqExactState(signups.user_id) AS signups, + uniqExactStateIf(signups.user_id, activated_at >= signed_up_at AND activated_at < signed_up_at + INTERVAL 7 DAY) AS activated_creators, + toUInt64(sum(if(activated_at >= signed_up_at AND activated_at < signed_up_at + INTERVAL 7 DAY, dateDiff('millisecond', signed_up_at, activated_at), 0))) AS time_to_activation_ms + FROM signups + LEFT JOIN activations USING (synthetic_run_id, user_id) + GROUP BY synthetic_run_id, cohort_date + {% if defined(copy_run_id) %} + UNION ALL + SELECT + now64(3) AS calculated_at, + {{String(copy_run_id)}} AS copy_run_id, + '' AS synthetic_run_id, + today() AS cohort_date, + uniqExactStateIf('', 0) AS signups, + uniqExactStateIf('', 0) AS activated_creators, + toUInt64(0) AS time_to_activation_ms + WHERE throwIf(length({{String(copy_run_id)}}) < 8 OR length({{String(copy_run_id)}}) > 128, 'copy_run_id has an invalid length') = 0 + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_activation_daily_exact +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_attribution_daily_exact.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_attribution_daily_exact.pipe new file mode 100644 index 00000000000..a09efd0ed3c --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_attribution_daily_exact.pipe @@ -0,0 +1,105 @@ +DESCRIPTION > + Rebuild exact campaign attribution for first, session, and last touch from canonical page views. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_attribution_daily_exact_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + WITH expanded_pageviews AS ( + SELECT + synthetic_run_id, + session_id, + anonymous_id, + event_id, + occurred_at, + platform, + app_version, + hostname, + country, + attribution_model, + multiIf( + attribution_model = 'first', JSONExtractString(properties, 'first_touch_source'), + attribution_model = 'session', JSONExtractString(properties, 'session_touch_source'), + JSONExtractString(properties, 'last_touch_source') + ) AS attribution_source, + multiIf( + attribution_model = 'first', JSONExtractString(properties, 'first_touch_medium'), + attribution_model = 'session', JSONExtractString(properties, 'session_touch_medium'), + JSONExtractString(properties, 'last_touch_medium') + ) AS attribution_medium, + multiIf( + attribution_model = 'first', JSONExtractString(properties, 'first_touch_campaign'), + attribution_model = 'session', JSONExtractString(properties, 'session_touch_campaign'), + JSONExtractString(properties, 'last_touch_campaign') + ) AS campaign + FROM product_events_canonical_current + ARRAY JOIN ['first', 'session', 'last'] AS attribution_model + WHERE event_name = 'page_view' + AND (traffic_class = 'external' OR synthetic_run_id != '') + AND session_id != '' + AND anonymous_id != '' + ), sessions AS ( + SELECT + synthetic_run_id, + session_id, + attribution_model, + concat('anonymous:', argMin(anonymous_id, tuple(occurred_at, event_id))) AS visitor_id, + min(occurred_at) AS started_at, + argMin(platform, tuple(occurred_at, event_id)) AS platform, + argMin(app_version, tuple(occurred_at, event_id)) AS app_version, + argMin(hostname, tuple(occurred_at, event_id)) AS hostname, + argMin(country, tuple(occurred_at, event_id)) AS country, + if(attribution_model = 'last', argMax(attribution_source, tuple(occurred_at, event_id)), argMin(attribution_source, tuple(occurred_at, event_id))) AS attribution_source, + if(attribution_model = 'last', argMax(attribution_medium, tuple(occurred_at, event_id)), argMin(attribution_medium, tuple(occurred_at, event_id))) AS attribution_medium, + if(attribution_model = 'last', argMax(campaign, tuple(occurred_at, event_id)), argMin(campaign, tuple(occurred_at, event_id))) AS campaign, + toUInt64(count()) AS session_pageviews + FROM expanded_pageviews + GROUP BY synthetic_run_id, session_id, attribution_model + ) + SELECT + now64(3) AS calculated_at, + '' AS copy_run_id, + synthetic_run_id, + toDate(started_at, 'UTC') AS date, + attribution_model, + platform, + app_version, + hostname, + country, + attribution_source, + attribution_medium, + campaign, + uniqExactState(visitor_id) AS visitors, + toUInt64(count()) AS visits, + toUInt64(sum(session_pageviews)) AS pageviews + FROM sessions + GROUP BY synthetic_run_id, date, attribution_model, platform, app_version, hostname, country, attribution_source, attribution_medium, campaign + {% if defined(copy_run_id) %} + UNION ALL + SELECT + now64(3) AS calculated_at, + {{String(copy_run_id)}} AS copy_run_id, + '' AS synthetic_run_id, + today() AS date, + 'session' AS attribution_model, + '' AS platform, + '' AS app_version, + '' AS hostname, + '' AS country, + '' AS attribution_source, + '' AS attribution_medium, + '' AS campaign, + uniqExactStateIf('', 0) AS visitors, + toUInt64(0) AS visits, + toUInt64(0) AS pageviews + WHERE throwIf(length({{String(copy_run_id)}}) < 8 OR length({{String(copy_run_id)}}) > 128, 'copy_run_id has an invalid length') = 0 + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_attribution_daily_exact +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_creator_retention_exact.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_creator_retention_exact.pipe new file mode 100644 index 00000000000..71e476a539f --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_creator_retention_exact.pipe @@ -0,0 +1,87 @@ +DESCRIPTION > + Rebuild active-creator cohorts from exact successful creation and sharing events. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_creator_retention_exact_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + WITH activity AS ( + SELECT DISTINCT + synthetic_run_id, + user_id, + organization_id, + toDate(occurred_at, 'UTC') AS activity_date, + platform + FROM product_events_canonical_current + WHERE event_name IN ('recording_started', 'recording_completed', 'multipart_upload_complete', 'share_link_created', 'loom_import_completed') + AND user_id != '' + AND (event_name != 'recording_completed' OR JSONExtractString(properties, 'status') IN ('success', 'degraded')) + ), user_cohorts AS ( + SELECT + synthetic_run_id, + user_id, + min(activity_date) AS cohort_date + FROM activity + GROUP BY synthetic_run_id, user_id + ), organization_cohorts AS ( + SELECT + synthetic_run_id, + organization_id, + min(activity_date) AS cohort_date + FROM activity + WHERE organization_id != '' + GROUP BY synthetic_run_id, organization_id + ), cohort_activity AS ( + SELECT + synthetic_run_id, + cohort_date, + activity_date, + platform, + user_id, + '' AS organization_id + FROM activity + INNER JOIN user_cohorts USING (synthetic_run_id, user_id) + UNION ALL + SELECT + synthetic_run_id, + cohort_date, + activity_date, + platform, + '' AS user_id, + organization_id + FROM activity + INNER JOIN organization_cohorts USING (synthetic_run_id, organization_id) + ) + SELECT + now64(3) AS calculated_at, + '' AS copy_run_id, + synthetic_run_id, + cohort_date, + activity_date, + platform, + uniqExactStateIf(user_id, user_id != '') AS creator_users, + uniqExactStateIf(organization_id, organization_id != '') AS creator_organizations + FROM cohort_activity + GROUP BY synthetic_run_id, cohort_date, activity_date, platform + {% if defined(copy_run_id) %} + UNION ALL + SELECT + now64(3) AS calculated_at, + {{String(copy_run_id)}} AS copy_run_id, + '' AS synthetic_run_id, + today() AS cohort_date, + today() AS activity_date, + '' AS platform, + uniqExactStateIf('', 0) AS creator_users, + uniqExactStateIf('', 0) AS creator_organizations + WHERE throwIf(length({{String(copy_run_id)}}) < 8 OR length({{String(copy_run_id)}}) > 128, 'copy_run_id has an invalid length') = 0 + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_creator_retention_exact +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_event_day_states_v2.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_event_day_states_v2.pipe new file mode 100644 index 00000000000..0d431146f26 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_event_day_states_v2.pipe @@ -0,0 +1,51 @@ +DESCRIPTION > + Exceptionally rebuild time-partitioned event state after erasure or during a controlled backfill. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_event_day_states_v2_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + SELECT + toDate(raw.occurred_at, 'UTC') AS occurred_date, + raw.event_id, + uniqExactState(raw.payload_hash) AS payload_hashes, + argMinState(raw.payload_hash, raw.received_at) AS payload_hash, + argMinState(raw.occurred_at, raw.received_at) AS occurred_at, + min(raw.received_at) AS first_received_at, + max(raw.received_at) AS received_at, + argMinState(toString(raw.event_name), raw.received_at) AS event_name, + argMinState(raw.schema_version, raw.received_at) AS schema_version, + argMinState(toString(raw.source), raw.received_at) AS source, + argMinState(toString(raw.platform), raw.received_at) AS platform, + argMinState(raw.anonymous_id, raw.received_at) AS anonymous_id, + argMinState(raw.session_id, raw.received_at) AS session_id, + argMinState(raw.user_id, raw.received_at) AS user_id, + argMinState(raw.organization_id, raw.received_at) AS organization_id, + argMinState(toString(raw.app_version), raw.received_at) AS app_version, + argMinState(raw.pathname, raw.received_at) AS pathname, + argMinState(raw.referrer, raw.received_at) AS referrer, + argMinState(toString(raw.country), raw.received_at) AS country, + argMinState(toString(raw.region), raw.received_at) AS region, + argMinState(toString(raw.city), raw.received_at) AS city, + argMinState(toString(raw.hostname), raw.received_at) AS hostname, + argMinState(toString(raw.browser), raw.received_at) AS browser, + argMinState(toString(raw.device), raw.received_at) AS device, + argMinState(toString(raw.os), raw.received_at) AS os, + argMinState(toString(raw.channel), raw.received_at) AS channel, + argMinState(toString(raw.traffic_class), raw.received_at) AS traffic_class, + argMinState(raw.synthetic_run_id, raw.received_at) AS synthetic_run_id, + argMinState(raw.properties, raw.received_at) AS properties + FROM product_events_v1 AS raw + {% if defined(source_cutoff) %} + WHERE raw.received_at <= toDateTime64({{DateTime64(source_cutoff)}}, 3) + {% end %} + GROUP BY toDate(raw.occurred_at, 'UTC'), raw.event_id + +TYPE COPY +TARGET_DATASOURCE product_event_day_states_v2 +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_event_id_states_v2.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_event_id_states_v2.pipe new file mode 100644 index 00000000000..7d976299049 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_event_id_states_v2.pipe @@ -0,0 +1,50 @@ +DESCRIPTION > + Rebuild per-event aggregate state after erasure or during a controlled backfill. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_event_id_states_v2_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + SELECT + raw.event_id, + uniqExactState(raw.payload_hash) AS payload_hashes, + argMinState(raw.payload_hash, raw.received_at) AS payload_hash, + argMinState(raw.occurred_at, raw.received_at) AS occurred_at, + min(raw.received_at) AS first_received_at, + max(raw.received_at) AS received_at, + argMinState(toString(raw.event_name), raw.received_at) AS event_name, + argMinState(raw.schema_version, raw.received_at) AS schema_version, + argMinState(toString(raw.source), raw.received_at) AS source, + argMinState(toString(raw.platform), raw.received_at) AS platform, + argMinState(raw.anonymous_id, raw.received_at) AS anonymous_id, + argMinState(raw.session_id, raw.received_at) AS session_id, + argMinState(raw.user_id, raw.received_at) AS user_id, + argMinState(raw.organization_id, raw.received_at) AS organization_id, + argMinState(toString(raw.app_version), raw.received_at) AS app_version, + argMinState(raw.pathname, raw.received_at) AS pathname, + argMinState(raw.referrer, raw.received_at) AS referrer, + argMinState(toString(raw.country), raw.received_at) AS country, + argMinState(toString(raw.region), raw.received_at) AS region, + argMinState(toString(raw.city), raw.received_at) AS city, + argMinState(toString(raw.hostname), raw.received_at) AS hostname, + argMinState(toString(raw.browser), raw.received_at) AS browser, + argMinState(toString(raw.device), raw.received_at) AS device, + argMinState(toString(raw.os), raw.received_at) AS os, + argMinState(toString(raw.channel), raw.received_at) AS channel, + argMinState(toString(raw.traffic_class), raw.received_at) AS traffic_class, + argMinState(raw.synthetic_run_id, raw.received_at) AS synthetic_run_id, + argMinState(raw.properties, raw.received_at) AS properties + FROM product_events_v1 AS raw + {% if defined(source_cutoff) %} + WHERE raw.received_at <= toDateTime64({{DateTime64(source_cutoff)}}, 3) + {% end %} + GROUP BY raw.event_id + +TYPE COPY +TARGET_DATASOURCE product_event_id_states_v2 +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_events_canonical_v1.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_events_canonical_v1.pipe new file mode 100644 index 00000000000..cc6d5807bfb --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_events_canonical_v1.pipe @@ -0,0 +1,21 @@ +DESCRIPTION > + Replace the canonical stream with one stable payload per event ID and quarantine conflicts. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_events_canonical_v1_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + SELECT * + FROM product_events_canonical_current + {% if defined(source_cutoff) %} + WHERE received_at <= toDateTime64({{DateTime64(source_cutoff)}}, 3) + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_events_canonical_v1 +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_events_daily_cold_v2.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_events_daily_cold_v2.pipe new file mode 100644 index 00000000000..5cc05162815 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_events_daily_cold_v2.pipe @@ -0,0 +1,18 @@ +DESCRIPTION > + Append one settled UTC event-decision partition for a controller generation. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_events_daily_cold_v2_node +SQL > + % + {% if not defined(settled_date) %} + {{ error('settled_date is required') }} + {% end %} + SELECT * + FROM product_events_daily_bounded_v2 + +TYPE COPY +TARGET_DATASOURCE product_events_daily_cold_v2 +COPY_MODE append +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_events_daily_exact.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_events_daily_exact.pipe new file mode 100644 index 00000000000..da6b0e19c44 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_events_daily_exact.pipe @@ -0,0 +1,109 @@ +DESCRIPTION > + Rebuild exact decision metrics from one non-conflicting canonical payload per event ID. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_events_daily_exact_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + SELECT + now64(3) AS calculated_at, + '' AS copy_run_id, + toDate(occurred_at, 'UTC') AS date, + event_name, + schema_version, + source, + platform, + app_version, + hostname, + country, + device, + browser, + os, + channel, + traffic_class, + synthetic_run_id, + if(event_name = 'subscription_changed', JSONExtractString(properties, 'new_price_id'), JSONExtractString(properties, 'price_id')) AS plan_id, + if( + event_name = 'recording_completed', + multiIf( + lower(JSONExtractString(properties, 'status')) = 'success', 'success', + lower(JSONExtractString(properties, 'status')) = 'degraded', 'degraded', + lower(JSONExtractString(properties, 'status')) = 'failed', 'failed', + 'unknown' + ), + '' + ) AS recording_status, + if(event_name = 'purchase_completed', JSONExtractString(properties, 'payment_status'), '') AS payment_status, + multiIf( + event_name IN ('purchase_completed', 'trial_started'), JSONExtractString(properties, 'subscription_status'), + event_name = 'trial_converted', JSONExtractString(properties, 'new_status'), + event_name = 'subscription_changed', JSONExtractString(properties, 'new_status'), + event_name = 'subscription_cancelled', JSONExtractString(properties, 'status'), + '' + ) AS subscription_status, + if(event_name IN ('purchase_completed', 'trial_started', 'subscription_renewed', 'subscription_refunded', 'subscription_payment_failed'), upper(JSONExtractString(properties, 'currency')), '') AS currency, + if(event_name IN ('purchase_completed', 'trial_started'), JSONExtractString(properties, 'billing_interval'), '') AS billing_interval, + if(event_name = 'subscription_changed', JSONExtractString(properties, 'change_kind'), '') AS change_kind, + if(event_name IN ('trial_converted', 'subscription_changed'), JSONExtractString(properties, 'previous_status'), '') AS previous_status, + if(event_name IN ('trial_converted', 'subscription_changed'), JSONExtractString(properties, 'new_status'), '') AS new_status, + if(event_name = 'subscription_changed', JSONExtractString(properties, 'previous_price_id'), '') AS previous_plan_id, + if(event_name IN ('checkout_started', 'guest_checkout_started', 'purchase_completed', 'trial_started'), JSONExtractInt(properties, 'quantity'), 0) AS quantity, + if(event_name = 'subscription_changed', JSONExtractInt(properties, 'previous_quantity'), 0) AS previous_quantity, + if(event_name = 'subscription_changed', JSONExtractInt(properties, 'new_quantity'), 0) AS new_quantity, + if(event_name = 'subscription_changed' AND JSONExtractString(properties, 'change_kind') = 'seats', JSONExtractInt(properties, 'new_quantity') - JSONExtractInt(properties, 'previous_quantity'), 0) AS seat_delta, + if(event_name = 'purchase_completed', if(JSONExtractBool(properties, 'is_first_purchase'), 'true', 'false'), '') AS first_purchase, + if(event_name IN ('purchase_completed', 'trial_started'), if(JSONExtractBool(properties, 'is_guest_checkout'), 'true', 'false'), '') AS guest_checkout, + if(event_name IN ('checkout_started', 'purchase_completed', 'trial_started'), if(JSONExtractBool(properties, 'is_onboarding'), 'true', 'false'), '') AS onboarding, + if(event_name = 'subscription_cancelled', if(JSONExtractBool(properties, 'cancel_at_period_end'), 'true', 'false'), '') AS cancel_at_period_end, + if(event_name = 'subscription_refunded', if(JSONExtractBool(properties, 'fully_refunded'), 'true', 'false'), '') AS fully_refunded, + if(event_name = 'subscription_cancelled', JSONExtractInt(properties, 'ended_at'), 0) AS ended_at, + if(event_name = 'trial_started', JSONExtractInt(properties, 'trial_end_at'), 0) AS trial_end_at, + if(event_name = 'subscription_payment_failed', JSONExtractInt(properties, 'amount_due_minor'), 0) AS amount_due_minor, + if(event_name = 'subscription_payment_failed', JSONExtractInt(properties, 'attempt_count'), 0) AS attempt_count, + if(event_name = 'experiment_exposed', JSONExtractString(properties, 'experiment_id'), '') AS experiment_id, + if(event_name = 'experiment_exposed', JSONExtractString(properties, 'variant'), '') AS experiment_variant, + if(event_name = 'experiment_exposed', JSONExtractString(properties, 'assignment_version'), '') AS assignment_version, + toUInt64(sum(if(event_name = 'analytics_delivery_loss', greatest(JSONExtractInt(properties, 'count'), 0), 0))) AS delivery_loss_count, + toUInt64(count()) AS events, + uniqExactState(if(user_id != '', concat('user:', user_id), if(anonymous_id != '', concat('anonymous:', anonymous_id), concat('session:', session_id)))) AS actors, + uniqExactStateIf(user_id, user_id != '') AS users, + uniqExactStateIf(organization_id, organization_id != '') AS organizations, + toInt64(sum(multiIf( + event_name = 'purchase_completed' AND payment_status = 'paid', JSONExtractInt(properties, 'amount_total_minor'), + event_name = 'subscription_renewed', JSONExtractInt(properties, 'amount_paid_minor'), + event_name = 'subscription_refunded', -JSONExtractInt(properties, 'amount_refunded_minor'), + 0 + ))) AS revenue_minor + FROM product_events_canonical_current + GROUP BY date, event_name, schema_version, source, platform, app_version, hostname, country, device, browser, os, channel, traffic_class, synthetic_run_id, plan_id, recording_status, payment_status, subscription_status, currency, billing_interval, change_kind, previous_status, new_status, previous_plan_id, quantity, previous_quantity, new_quantity, seat_delta, first_purchase, guest_checkout, onboarding, cancel_at_period_end, fully_refunded, ended_at, trial_end_at, amount_due_minor, attempt_count, experiment_id, experiment_variant, assignment_version + {% if defined(copy_run_id) %} + UNION ALL + SELECT + now64(3), + {{String(copy_run_id)}}, + today(), + '', + toUInt16(0), + '', '', '', '', '', '', '', '', '', '', '', + '', '', '', '', '', '', '', '', '', '', + toInt64(0), toInt64(0), toInt64(0), toInt64(0), + '', '', '', '', '', + toInt64(0), toInt64(0), toInt64(0), toInt64(0), + '', '', '', + toUInt64(0), + toUInt64(0), + uniqExactStateIf('', 0), + uniqExactStateIf('', 0), + uniqExactStateIf('', 0), + toInt64(0) + WHERE throwIf(length({{String(copy_run_id)}}) < 8 OR length({{String(copy_run_id)}}) > 128, 'copy_run_id has an invalid length') = 0 + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_events_daily_exact +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_events_daily_hot_v2.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_events_daily_hot_v2.pipe new file mode 100644 index 00000000000..7b2dffda3a8 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_events_daily_hot_v2.pipe @@ -0,0 +1,14 @@ +DESCRIPTION > + Replace only the bounded mutable event-decision window for one controller generation. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_events_daily_hot_v2_node +SQL > + SELECT * + FROM product_events_daily_bounded_v2 + +TYPE COPY +TARGET_DATASOURCE product_events_daily_hot_v2 +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_events_health_hourly.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_events_health_hourly.pipe new file mode 100644 index 00000000000..93a5f7039b6 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_events_health_hourly.pipe @@ -0,0 +1,48 @@ +DESCRIPTION > + Rebuild hourly delivery health so privacy erasure retracts all derived state. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_events_health_hourly_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + SELECT + toStartOfHour(received_at) AS hour, + now64(3) AS calculated_at, + '' AS copy_run_id, + platform, + app_version, + count() AS received_rows, + uniqExactState(event_id) AS unique_events, + uniqExactState(tuple(event_id, payload_hash)) AS unique_payloads, + countIf(occurred_at > received_at + INTERVAL 5 MINUTE) AS future_events, + countIf(occurred_at < received_at - INTERVAL 1 DAY) AS late_events, + countIf(user_id = '' AND anonymous_id = '' AND session_id = '') AS missing_identity_events, + quantilesTDigestState(0.5, 0.95, 0.99)(toFloat64(dateDiff('millisecond', occurred_at, received_at))) AS ingestion_lag_ms + FROM product_events_v1 + GROUP BY hour, platform, app_version + {% if defined(copy_run_id) %} + UNION ALL + SELECT + toStartOfHour(now()), + now64(3), + {{String(copy_run_id)}}, + '', + '', + toUInt64(0), + uniqExactStateIf('', 0), + uniqExactStateIf(tuple('', toFixedString('', 32)), 0), + toUInt64(0), + toUInt64(0), + toUInt64(0), + quantilesTDigestState(0.5, 0.95, 0.99)(toFloat64(0)) + WHERE throwIf(length({{String(copy_run_id)}}) < 8 OR length({{String(copy_run_id)}}) > 128, 'copy_run_id has an invalid length') = 0 + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_events_health_hourly_exact +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_experiment_outcomes_exact.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_experiment_outcomes_exact.pipe new file mode 100644 index 00000000000..161523dedea --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_experiment_outcomes_exact.pipe @@ -0,0 +1,145 @@ +DESCRIPTION > + Rebuild aggregate 30-day first-outcome cohorts anchored only to explicit, stable experiment exposures. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_experiment_outcomes_exact_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + WITH identity_candidates AS ( + SELECT synthetic_run_id, anonymous_id, user_id, event_id, occurred_at + FROM product_events_canonical_current + WHERE event_name = 'identity_linked' + AND anonymous_id != '' + AND user_id != '' + UNION ALL + SELECT synthetic_run_id, anonymous_id, user_id, event_id, occurred_at + FROM product_events_canonical_current + WHERE event_name = 'purchase_completed' + AND JSONExtractString(properties, 'payment_status') = 'paid' + AND JSONExtractBool(properties, 'is_guest_checkout') + AND anonymous_id != '' + AND user_id != '' + ), identity_links AS ( + SELECT + candidates.synthetic_run_id, + candidates.anonymous_id, + argMin(candidates.user_id, tuple(candidates.occurred_at, candidates.event_id)) AS user_id + FROM identity_candidates AS candidates + GROUP BY candidates.synthetic_run_id, candidates.anonymous_id + HAVING uniqExact(candidates.user_id) = 1 + ), resolved_exposures AS ( + SELECT + exposures.synthetic_run_id AS synthetic_run_id, + if(exposures.user_id != '', exposures.user_id, coalesce(identity_links.user_id, '')) AS resolved_user_id, + if( + if(exposures.user_id != '', exposures.user_id, coalesce(identity_links.user_id, '')) != '', + concat('user:', if(exposures.user_id != '', exposures.user_id, coalesce(identity_links.user_id, ''))), + concat('anonymous:', exposures.anonymous_id) + ) AS actor_id, + exposures.occurred_at AS occurred_at, + exposures.event_id AS event_id, + JSONExtractString(exposures.properties, 'experiment_id') AS experiment_id, + JSONExtractString(exposures.properties, 'assignment_version') AS assignment_version, + JSONExtractString(exposures.properties, 'variant') AS variant, + exposures.platform AS platform, + exposures.app_version AS app_version + FROM product_events_canonical_current AS exposures + LEFT JOIN identity_links ON exposures.synthetic_run_id = identity_links.synthetic_run_id AND exposures.anonymous_id = identity_links.anonymous_id + WHERE exposures.event_name = 'experiment_exposed' + AND (exposures.traffic_class = 'external' OR exposures.synthetic_run_id != '') + AND (exposures.user_id != '' OR exposures.anonymous_id != '') + ), exposures AS ( + SELECT + resolved.synthetic_run_id, + resolved.actor_id, + argMin(resolved.resolved_user_id, tuple(resolved.occurred_at, resolved.event_id)) AS user_id, + min(resolved.occurred_at) AS exposed_at, + resolved.experiment_id, + resolved.assignment_version, + argMin(resolved.variant, tuple(resolved.occurred_at, resolved.event_id)) AS variant, + argMin(resolved.platform, tuple(resolved.occurred_at, resolved.event_id)) AS platform, + argMin(resolved.app_version, tuple(resolved.occurred_at, resolved.event_id)) AS app_version + FROM resolved_exposures AS resolved + WHERE resolved.experiment_id != '' + AND resolved.assignment_version != '' + AND resolved.variant != '' + GROUP BY resolved.synthetic_run_id, resolved.actor_id, resolved.experiment_id, resolved.assignment_version + HAVING uniqExact(resolved.variant) = 1 + ), outcome_candidates AS ( + SELECT + synthetic_run_id, + user_id, + event_name, + occurred_at + FROM product_events_canonical_current + WHERE user_id != '' + AND (traffic_class = 'external' OR synthetic_run_id != '') + AND event_name IN ('user_signed_up', 'share_link_created', 'purchase_completed') + AND (event_name != 'purchase_completed' OR JSONExtractString(properties, 'payment_status') = 'paid') + ), exposure_outcomes AS ( + SELECT + exposures.synthetic_run_id AS synthetic_run_id, + exposures.actor_id AS actor_id, + exposures.user_id AS user_id, + exposures.exposed_at AS exposed_at, + exposures.experiment_id AS experiment_id, + exposures.assignment_version AS assignment_version, + exposures.variant AS variant, + exposures.platform AS platform, + exposures.app_version AS app_version, + minIf(outcomes.occurred_at, outcomes.event_name = 'user_signed_up' AND outcomes.occurred_at >= exposures.exposed_at AND outcomes.occurred_at < exposures.exposed_at + INTERVAL 30 DAY) AS signed_up_at, + minIf(outcomes.occurred_at, outcomes.event_name = 'share_link_created' AND outcomes.occurred_at >= exposures.exposed_at AND outcomes.occurred_at < exposures.exposed_at + INTERVAL 30 DAY) AS shared_at, + minIf(outcomes.occurred_at, outcomes.event_name = 'purchase_completed' AND outcomes.occurred_at >= exposures.exposed_at AND outcomes.occurred_at < exposures.exposed_at + INTERVAL 30 DAY) AS purchased_at + FROM exposures + LEFT JOIN outcome_candidates AS outcomes ON exposures.synthetic_run_id = outcomes.synthetic_run_id AND exposures.user_id = outcomes.user_id + GROUP BY exposures.synthetic_run_id, exposures.actor_id, exposures.user_id, exposures.exposed_at, exposures.experiment_id, exposures.assignment_version, exposures.variant, exposures.platform, exposures.app_version + ), outcome_rows AS ( + SELECT *, 'signup' AS outcome_name, signed_up_at AS outcome_at FROM exposure_outcomes + UNION ALL + SELECT *, 'share_created' AS outcome_name, shared_at AS outcome_at FROM exposure_outcomes + UNION ALL + SELECT *, 'paid_purchase' AS outcome_name, purchased_at AS outcome_at FROM exposure_outcomes + ) + SELECT + now64(3) AS calculated_at, + '' AS copy_run_id, + synthetic_run_id, + toDate(exposed_at, 'UTC') AS cohort_date, + experiment_id, + assignment_version, + variant, + platform, + app_version, + outcome_name, + toUInt64(count()) AS exposed_actors, + toUInt64(countIf(outcome_at >= exposed_at AND outcome_at < exposed_at + INTERVAL 30 DAY)) AS converted_actors, + toUInt8(0) AS copy_marker + FROM outcome_rows + GROUP BY synthetic_run_id, cohort_date, experiment_id, assignment_version, variant, platform, app_version, outcome_name + {% if defined(copy_run_id) %} + UNION ALL + SELECT + now64(3) AS calculated_at, + {{String(copy_run_id)}} AS copy_run_id, + '' AS synthetic_run_id, + today() AS cohort_date, + '' AS experiment_id, + '' AS assignment_version, + '' AS variant, + '' AS platform, + '' AS app_version, + '' AS outcome_name, + toUInt64(0) AS exposed_actors, + toUInt64(0) AS converted_actors, + toUInt8(1) AS copy_marker + WHERE throwIf(length({{String(copy_run_id)}}) < 8 OR length({{String(copy_run_id)}}) > 128, 'copy_run_id has an invalid length') = 0 + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_experiment_outcomes_exact +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_identity_funnel_exact.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_identity_funnel_exact.pipe new file mode 100644 index 00000000000..f0cf811b349 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_identity_funnel_exact.pipe @@ -0,0 +1,234 @@ +DESCRIPTION > + Rebuild privacy-safe cohorts that join pre-auth acquisition to authoritative user outcomes without exposing identity mappings. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_identity_funnel_exact_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + WITH identity_links AS ( + SELECT synthetic_run_id, user_id, anonymous_id, organization_id, event_id, occurred_at + FROM product_events_canonical_current + WHERE event_name = 'identity_linked' + AND user_id != '' + AND anonymous_id != '' + ), paid_guest_links AS ( + SELECT synthetic_run_id, user_id, anonymous_id, organization_id, event_id, occurred_at + FROM product_events_canonical_current + WHERE event_name = 'purchase_completed' + AND JSONExtractString(properties, 'payment_status') = 'paid' + AND JSONExtractBool(properties, 'is_guest_checkout') + AND user_id != '' + AND anonymous_id != '' + ), link_candidates AS ( + SELECT * FROM identity_links + UNION ALL + SELECT * FROM paid_guest_links + ), first_links AS ( + SELECT + candidates.synthetic_run_id, + candidates.user_id, + argMin(candidates.anonymous_id, tuple(candidates.occurred_at, candidates.event_id)) AS anonymous_id, + argMinIf(candidates.organization_id, tuple(candidates.occurred_at, candidates.event_id), candidates.organization_id != '') AS organization_id, + min(candidates.occurred_at) AS linked_at + FROM link_candidates AS candidates + GROUP BY candidates.synthetic_run_id, candidates.user_id + ), organization_first_links AS ( + SELECT + synthetic_run_id, + organization_id, + argMin(user_id, tuple(linked_at, user_id)) AS first_user_id + FROM first_links + WHERE organization_id != '' + GROUP BY synthetic_run_id, organization_id + ), acquisition AS ( + SELECT + synthetic_run_id, + anonymous_id, + min(occurred_at) AS acquired_at, + argMin(channel, tuple(occurred_at, event_id)) AS channel, + argMin(JSONExtractString(properties, 'session_touch_source'), tuple(occurred_at, event_id)) AS attribution_source, + argMin(JSONExtractString(properties, 'session_touch_medium'), tuple(occurred_at, event_id)) AS attribution_medium, + argMin(JSONExtractString(properties, 'session_touch_campaign'), tuple(occurred_at, event_id)) AS campaign, + argMin(country, tuple(occurred_at, event_id)) AS country + FROM product_events_canonical_current + WHERE event_name = 'page_view' + AND anonymous_id != '' + AND (traffic_class = 'external' OR synthetic_run_id != '') + GROUP BY synthetic_run_id, anonymous_id + ), outcomes AS ( + SELECT + synthetic_run_id, + user_id, + toUInt64(countIf(event_name = 'user_signed_up') > 0) AS signed_up, + toUInt64(countIf(event_name = 'checkout_started') > 0) AS authenticated_checkout, + toUInt64(countIf(event_name = 'checkout_started' AND platform = 'web') > 0) AS web_checkout, + toUInt64(countIf(event_name = 'checkout_started' AND platform = 'desktop') > 0) AS desktop_checkout, + toUInt64(countIf(event_name = 'checkout_started' AND platform = 'mobile') > 0) AS mobile_checkout, + toUInt64(uniqExactIf(platform, event_name = 'checkout_started') > 1) AS cross_device_checkout, + toUInt64(countIf(event_name = 'trial_started') > 0) AS trial_started, + toUInt64(countIf(event_name = 'purchase_completed' AND JSONExtractString(properties, 'payment_status') = 'paid') > 0) AS purchased, + toUInt64(countIf(event_name = 'purchase_completed' AND JSONExtractString(properties, 'payment_status') = 'paid' AND JSONExtractBool(properties, 'is_guest_checkout')) > 0) AS guest_purchased + FROM product_events_canonical_current + WHERE user_id != '' + GROUP BY synthetic_run_id, user_id + ), guest_checkouts AS ( + SELECT + synthetic_run_id, + anonymous_id, + min(occurred_at) AS checkout_at, + toUInt64(count() > 0) AS guest_checkout + FROM product_events_canonical_current + WHERE event_name = 'guest_checkout_started' + AND anonymous_id != '' + GROUP BY synthetic_run_id, anonymous_id + ), guest_path_candidates AS ( + SELECT + link_candidates.synthetic_run_id AS synthetic_run_id, + link_candidates.user_id AS user_id, + link_candidates.anonymous_id AS anonymous_id, + link_candidates.organization_id AS organization_id, + guest_checkouts.checkout_at AS path_at, + toUInt64(0) AS paid_guest_purchase + FROM link_candidates + INNER JOIN guest_checkouts ON link_candidates.synthetic_run_id = guest_checkouts.synthetic_run_id AND link_candidates.anonymous_id = guest_checkouts.anonymous_id + WHERE guest_checkouts.checkout_at <= link_candidates.occurred_at + UNION ALL + SELECT + synthetic_run_id, + user_id, + anonymous_id, + organization_id, + occurred_at AS path_at, + toUInt64(1) AS paid_guest_purchase + FROM paid_guest_links + ), guest_paths AS ( + SELECT + candidates.synthetic_run_id, + candidates.user_id, + argMin(candidates.anonymous_id, tuple(if(candidates.paid_guest_purchase > 0, 0, 1), candidates.path_at, candidates.anonymous_id)) AS anonymous_id, + argMin(candidates.organization_id, tuple(if(candidates.paid_guest_purchase > 0, 0, 1), candidates.path_at, candidates.anonymous_id)) AS organization_id, + argMin(candidates.path_at, tuple(if(candidates.paid_guest_purchase > 0, 0, 1), candidates.path_at, candidates.anonymous_id)) AS linked_at, + toUInt64(max(candidates.paid_guest_purchase) > 0) AS guest_purchased + FROM guest_path_candidates AS candidates + GROUP BY candidates.synthetic_run_id, candidates.user_id + ), user_linked_outcomes AS ( + SELECT + first_links.synthetic_run_id AS synthetic_run_id, + first_links.user_id AS user_id, + first_links.anonymous_id AS anonymous_id, + first_links.organization_id AS organization_id, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > first_links.linked_at, first_links.linked_at, acquisition.acquired_at) AS acquired_at, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > first_links.linked_at, '', acquisition.channel) AS channel, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > first_links.linked_at, '', acquisition.attribution_source) AS attribution_source, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > first_links.linked_at, '', acquisition.attribution_medium) AS attribution_medium, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > first_links.linked_at, '', acquisition.campaign) AS campaign, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > first_links.linked_at, '', acquisition.country) AS country, + toUInt64(1) AS linked_user, + toUInt64(first_links.organization_id != '' AND organization_first_links.first_user_id = first_links.user_id) AS linked_organization, + outcomes.signed_up AS signed_up, + toUInt64(0) AS guest_checkout, + toUInt64(0) AS guest_purchased, + outcomes.authenticated_checkout AS authenticated_checkout, + outcomes.web_checkout AS web_checkout, + outcomes.desktop_checkout AS desktop_checkout, + outcomes.mobile_checkout AS mobile_checkout, + outcomes.cross_device_checkout AS cross_device_checkout, + outcomes.trial_started AS trial_started, + outcomes.purchased AS purchased + FROM first_links + LEFT JOIN acquisition ON first_links.synthetic_run_id = acquisition.synthetic_run_id AND first_links.anonymous_id = acquisition.anonymous_id + LEFT JOIN organization_first_links ON first_links.synthetic_run_id = organization_first_links.synthetic_run_id AND first_links.organization_id = organization_first_links.organization_id + LEFT JOIN outcomes ON first_links.synthetic_run_id = outcomes.synthetic_run_id AND first_links.user_id = outcomes.user_id + ), guest_linked_outcomes AS ( + SELECT + guest_checkouts.synthetic_run_id AS synthetic_run_id, + guest_paths.user_id AS user_id, + guest_checkouts.anonymous_id AS anonymous_id, + guest_paths.organization_id AS organization_id, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > guest_checkouts.checkout_at, guest_checkouts.checkout_at, acquisition.acquired_at) AS acquired_at, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > guest_checkouts.checkout_at, '', acquisition.channel) AS channel, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > guest_checkouts.checkout_at, '', acquisition.attribution_source) AS attribution_source, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > guest_checkouts.checkout_at, '', acquisition.attribution_medium) AS attribution_medium, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > guest_checkouts.checkout_at, '', acquisition.campaign) AS campaign, + if(acquisition.anonymous_id = '' OR acquisition.acquired_at > guest_checkouts.checkout_at, '', acquisition.country) AS country, + toUInt64(0) AS linked_user, + toUInt64(0) AS linked_organization, + toUInt64(0) AS signed_up, + guest_checkouts.guest_checkout AS guest_checkout, + guest_paths.guest_purchased AS guest_purchased, + toUInt64(0) AS authenticated_checkout, + toUInt64(0) AS web_checkout, + toUInt64(0) AS desktop_checkout, + toUInt64(0) AS mobile_checkout, + toUInt64(0) AS cross_device_checkout, + toUInt64(0) AS trial_started, + toUInt64(0) AS purchased + FROM guest_checkouts + LEFT JOIN guest_paths ON guest_checkouts.synthetic_run_id = guest_paths.synthetic_run_id AND guest_checkouts.anonymous_id = guest_paths.anonymous_id + LEFT JOIN acquisition ON guest_checkouts.synthetic_run_id = acquisition.synthetic_run_id AND guest_checkouts.anonymous_id = acquisition.anonymous_id + ), linked_outcomes AS ( + SELECT * FROM user_linked_outcomes + UNION ALL + SELECT * FROM guest_linked_outcomes + ) + SELECT + now64(3) AS calculated_at, + '' AS copy_run_id, + synthetic_run_id, + toDate(acquired_at, 'UTC') AS cohort_date, + channel, + attribution_source, + attribution_medium, + campaign, + country, + toUInt64(uniqExactIf(anonymous_id, linked_user > 0)) AS linked_visitors, + toUInt64(sum(linked_user)) AS linked_users, + toUInt64(sum(signed_up)) AS signup_users, + toUInt64(sum(linked_organization)) AS organizations, + toUInt64(uniqExactIf(anonymous_id, guest_checkout > 0)) AS guest_checkout_visitors, + toUInt64(sum(guest_purchased)) AS guest_purchasers, + toUInt64(sum(authenticated_checkout)) AS authenticated_checkout_users, + toUInt64(sum(web_checkout)) AS web_checkout_users, + toUInt64(sum(desktop_checkout)) AS desktop_checkout_users, + toUInt64(sum(mobile_checkout)) AS mobile_checkout_users, + toUInt64(sum(cross_device_checkout)) AS cross_device_checkout_users, + toUInt64(sum(trial_started)) AS trial_users, + toUInt64(sum(purchased)) AS purchasers + FROM linked_outcomes + GROUP BY synthetic_run_id, cohort_date, channel, attribution_source, attribution_medium, campaign, country + {% if defined(copy_run_id) %} + UNION ALL + SELECT + now64(3) AS calculated_at, + {{String(copy_run_id)}} AS copy_run_id, + '' AS synthetic_run_id, + today() AS cohort_date, + '' AS channel, + '' AS attribution_source, + '' AS attribution_medium, + '' AS campaign, + '' AS country, + toUInt64(1) AS linked_visitors, + toUInt64(0) AS linked_users, + toUInt64(0) AS signup_users, + toUInt64(0) AS organizations, + toUInt64(0) AS guest_checkout_visitors, + toUInt64(0) AS guest_purchasers, + toUInt64(0) AS authenticated_checkout_users, + toUInt64(0) AS web_checkout_users, + toUInt64(0) AS desktop_checkout_users, + toUInt64(0) AS mobile_checkout_users, + toUInt64(0) AS cross_device_checkout_users, + toUInt64(0) AS trial_users, + toUInt64(0) AS purchasers + WHERE throwIf(length({{String(copy_run_id)}}) < 8 OR length({{String(copy_run_id)}}) > 128, 'copy_run_id has an invalid length') = 0 + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_identity_funnel_exact +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_traffic_daily_cold_v2.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_daily_cold_v2.pipe new file mode 100644 index 00000000000..79c11736847 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_daily_cold_v2.pipe @@ -0,0 +1,18 @@ +DESCRIPTION > + Append one settled UTC traffic-session partition for a controller generation. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_traffic_daily_cold_v2_node +SQL > + % + {% if not defined(settled_date) %} + {{ error('settled_date is required') }} + {% end %} + SELECT * + FROM product_traffic_daily_bounded_v2 + +TYPE COPY +TARGET_DATASOURCE product_traffic_daily_cold_v2 +COPY_MODE append +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_traffic_daily_exact.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_daily_exact.pipe new file mode 100644 index 00000000000..260fccc3028 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_daily_exact.pipe @@ -0,0 +1,103 @@ +DESCRIPTION > + Rebuild exact 30-minute client-session metrics from canonical external page events. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_traffic_daily_exact_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + WITH session_engagement AS ( + SELECT + synthetic_run_id, + session_id, + toUInt64(sum(greatest(0, JSONExtractInt(properties, 'engaged_ms')))) AS session_engaged_ms, + max(greatest(0, least(100, JSONExtractInt(properties, 'max_scroll_depth')))) AS session_max_scroll_depth + FROM product_events_canonical_current + WHERE event_name = 'page_engagement' + AND (traffic_class = 'external' OR synthetic_run_id != '') + AND session_id != '' + GROUP BY synthetic_run_id, session_id + ), sessions AS ( + SELECT + synthetic_run_id, + session_id, + concat('anonymous:', argMin(anonymous_id, tuple(occurred_at, event_id))) AS visitor_id, + min(occurred_at) AS started_at, + argMin(platform, tuple(occurred_at, event_id)) AS platform, + argMin(app_version, tuple(occurred_at, event_id)) AS app_version, + argMin(hostname, tuple(occurred_at, event_id)) AS hostname, + argMin(country, tuple(occurred_at, event_id)) AS country, + argMin(device, tuple(occurred_at, event_id)) AS device, + argMin(browser, tuple(occurred_at, event_id)) AS browser, + argMin(os, tuple(occurred_at, event_id)) AS os, + argMin(channel, tuple(occurred_at, event_id)) AS channel, + argMin(JSONExtractString(properties, 'session_touch_source'), tuple(occurred_at, event_id)) AS attribution_source, + argMin(JSONExtractString(properties, 'session_touch_medium'), tuple(occurred_at, event_id)) AS attribution_medium, + argMin(JSONExtractString(properties, 'session_touch_campaign'), tuple(occurred_at, event_id)) AS campaign, + toUInt64(count()) AS session_pageviews + FROM product_events_canonical_current + WHERE event_name = 'page_view' + AND (traffic_class = 'external' OR synthetic_run_id != '') + AND session_id != '' + AND anonymous_id != '' + GROUP BY synthetic_run_id, session_id + ) + SELECT + now64(3) AS calculated_at, + '' AS copy_run_id, + synthetic_run_id, + toDate(started_at, 'UTC') AS date, + platform, + app_version, + hostname, + country, + device, + browser, + os, + channel, + attribution_source, + attribution_medium, + campaign, + uniqExactState(visitor_id) AS visitors, + toUInt64(count()) AS visits, + toUInt64(sum(session_pageviews)) AS pageviews, + toUInt64(sum(if(session_pageviews = 1 AND coalesce(session_engaged_ms, 0) < 10000 AND coalesce(session_max_scroll_depth, 0) < 50, 1, 0))) AS bounces, + toUInt64(sum(coalesce(session_engaged_ms, 0))) AS visit_duration_ms, + toUInt64(sum(coalesce(session_engaged_ms, 0))) AS engaged_ms + FROM sessions + LEFT JOIN session_engagement USING (synthetic_run_id, session_id) + GROUP BY synthetic_run_id, date, platform, app_version, hostname, country, device, browser, os, channel, attribution_source, attribution_medium, campaign + {% if defined(copy_run_id) %} + UNION ALL + SELECT + now64(3) AS calculated_at, + {{String(copy_run_id)}} AS copy_run_id, + '' AS synthetic_run_id, + today() AS date, + '' AS platform, + '' AS app_version, + '' AS hostname, + '' AS country, + '' AS device, + '' AS browser, + '' AS os, + '' AS channel, + '' AS attribution_source, + '' AS attribution_medium, + '' AS campaign, + uniqExactStateIf('', 0) AS visitors, + toUInt64(0) AS visits, + toUInt64(0) AS pageviews, + toUInt64(0) AS bounces, + toUInt64(0) AS visit_duration_ms, + toUInt64(0) AS engaged_ms + WHERE throwIf(length({{String(copy_run_id)}}) < 8 OR length({{String(copy_run_id)}}) > 128, 'copy_run_id has an invalid length') = 0 + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_traffic_daily_exact +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_traffic_daily_hot_v2.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_daily_hot_v2.pipe new file mode 100644 index 00000000000..5e3d436007e --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_daily_hot_v2.pipe @@ -0,0 +1,14 @@ +DESCRIPTION > + Replace only the bounded mutable traffic-session window for one controller generation. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_traffic_daily_hot_v2_node +SQL > + SELECT * + FROM product_traffic_daily_bounded_v2 + +TYPE COPY +TARGET_DATASOURCE product_traffic_daily_hot_v2 +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_traffic_pages_daily_cold_v2.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_pages_daily_cold_v2.pipe new file mode 100644 index 00000000000..311b6049de8 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_pages_daily_cold_v2.pipe @@ -0,0 +1,18 @@ +DESCRIPTION > + Append one settled UTC page-decision partition for a controller generation. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_traffic_pages_daily_cold_v2_node +SQL > + % + {% if not defined(settled_date) %} + {{ error('settled_date is required') }} + {% end %} + SELECT * + FROM product_traffic_pages_daily_bounded_v2 + +TYPE COPY +TARGET_DATASOURCE product_traffic_pages_daily_cold_v2 +COPY_MODE append +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_traffic_pages_daily_exact.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_pages_daily_exact.pipe new file mode 100644 index 00000000000..8b6e1d65c6a --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_pages_daily_exact.pipe @@ -0,0 +1,109 @@ +DESCRIPTION > + Rebuild exact external page, landing, exit, and foreground-engagement metrics. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_traffic_pages_daily_exact_node +SQL > + % + {% if defined(copy_max_threads) %} + {{max_threads(Int32(copy_max_threads))}} + {% end %} + WITH sessions AS ( + SELECT + synthetic_run_id, + session_id, + argMin(event_id, tuple(occurred_at, event_id)) AS landing_event_id, + argMax(event_id, tuple(occurred_at, event_id)) AS exit_event_id + FROM product_events_canonical_current + WHERE event_name = 'page_view' + AND (traffic_class = 'external' OR synthetic_run_id != '') + AND session_id != '' + GROUP BY synthetic_run_id, session_id + ), engagements AS ( + SELECT + synthetic_run_id, + JSONExtractString(properties, 'page_view_id') AS page_view_id, + toUInt64(sum(greatest(0, JSONExtractInt(properties, 'engaged_ms')))) AS engaged_ms, + toFloat64(max(greatest(0, least(100, JSONExtractInt(properties, 'max_scroll_depth'))))) AS scroll_depth + FROM product_events_canonical_current + WHERE event_name = 'page_engagement' + AND (traffic_class = 'external' OR synthetic_run_id != '') + GROUP BY synthetic_run_id, page_view_id + ), page_views AS ( + SELECT + synthetic_run_id, + event_id, + occurred_at, + session_id, + concat('anonymous:', anonymous_id) AS visitor_id, + platform, + app_version, + hostname, + pathname, + country, + device, + browser, + os, + channel + FROM product_events_canonical_current + WHERE event_name = 'page_view' + AND (traffic_class = 'external' OR synthetic_run_id != '') + AND session_id != '' + AND anonymous_id != '' + ) + SELECT + now64(3) AS calculated_at, + '' AS copy_run_id, + page_views.synthetic_run_id AS synthetic_run_id, + toDate(occurred_at, 'UTC') AS date, + platform, + app_version, + hostname, + pathname, + country, + device, + browser, + os, + channel, + uniqExactState(visitor_id) AS visitors, + uniqExactState(page_views.session_id) AS visits, + toUInt64(count()) AS pageviews, + toUInt64(countIf(event_id = landing_event_id)) AS landings, + toUInt64(countIf(event_id = exit_event_id)) AS exits, + toUInt64(sum(coalesce(engaged_ms, 0))) AS engaged_ms, + toFloat64(sum(coalesce(scroll_depth, 0))) AS scroll_depth_sum + FROM page_views + INNER JOIN sessions ON page_views.synthetic_run_id = sessions.synthetic_run_id AND page_views.session_id = sessions.session_id + LEFT JOIN engagements ON page_views.synthetic_run_id = engagements.synthetic_run_id AND page_views.event_id = engagements.page_view_id + GROUP BY page_views.synthetic_run_id, date, platform, app_version, hostname, pathname, country, device, browser, os, channel + {% if defined(copy_run_id) %} + UNION ALL + SELECT + now64(3) AS calculated_at, + {{String(copy_run_id)}} AS copy_run_id, + '' AS synthetic_run_id, + today() AS date, + '' AS platform, + '' AS app_version, + '' AS hostname, + '' AS pathname, + '' AS country, + '' AS device, + '' AS browser, + '' AS os, + '' AS channel, + uniqExactStateIf('', 0) AS visitors, + uniqExactStateIf('', 0) AS visits, + toUInt64(0) AS pageviews, + toUInt64(0) AS landings, + toUInt64(0) AS exits, + toUInt64(0) AS engaged_ms, + toFloat64(0) AS scroll_depth_sum + WHERE throwIf(length({{String(copy_run_id)}}) < 8 OR length({{String(copy_run_id)}}) > 128, 'copy_run_id has an invalid length') = 0 + {% end %} + +TYPE COPY +TARGET_DATASOURCE product_traffic_pages_daily_exact +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/pipes/snapshot_product_traffic_pages_daily_hot_v2.pipe b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_pages_daily_hot_v2.pipe new file mode 100644 index 00000000000..ccfa2db9060 --- /dev/null +++ b/scripts/analytics/tinybird/pipes/snapshot_product_traffic_pages_daily_hot_v2.pipe @@ -0,0 +1,14 @@ +DESCRIPTION > + Replace only the bounded mutable page-decision window for one controller generation. + +TOKEN product_events_copy_runner READ + +NODE snapshot_product_traffic_pages_daily_hot_v2_node +SQL > + SELECT * + FROM product_traffic_pages_daily_bounded_v2 + +TYPE COPY +TARGET_DATASOURCE product_traffic_pages_daily_hot_v2 +COPY_MODE replace +COPY_SCHEDULE @on-demand diff --git a/scripts/analytics/tinybird/tests/product_creator_retention.yaml b/scripts/analytics/tinybird/tests/product_creator_retention.yaml new file mode 100644 index 00000000000..321bc270a12 --- /dev/null +++ b/scripts/analytics/tinybird/tests/product_creator_retention.yaml @@ -0,0 +1,15 @@ +- name: product_creator_retention_cross_platform + description: Merges the same creator across desktop and mobile before calculating retention. + expected_http_status: 200 + parameters: start_date=2099-01-10&end_date=2099-01-10 + expected_result: | + {"cohort_date":"2099-01-10","activity_date":"2099-01-10","cohort_day":0,"platform":"all","creators":1,"organizations":1} + {"cohort_date":"2099-01-10","activity_date":"2099-01-11","cohort_day":1,"platform":"all","creators":1,"organizations":1} + +- name: product_creator_retention_activity_platform + description: Keeps the all-platform cohort baseline when filtering retained activity by platform. + expected_http_status: 200 + parameters: start_date=2099-01-10&end_date=2099-01-10&platform=mobile + expected_result: | + {"cohort_date":"2099-01-10","activity_date":"2099-01-10","cohort_day":0,"platform":"mobile","creators":1,"organizations":1} + {"cohort_date":"2099-01-10","activity_date":"2099-01-11","cohort_day":1,"platform":"mobile","creators":1,"organizations":1} diff --git a/scripts/analytics/tinybird/tests/product_events_daily.yaml b/scripts/analytics/tinybird/tests/product_events_daily.yaml new file mode 100644 index 00000000000..4d6f28797bb --- /dev/null +++ b/scripts/analytics/tinybird/tests/product_events_daily.yaml @@ -0,0 +1,27 @@ +- name: product_events_daily_recording_started + description: Counts unique recording starts by day, source and platform. + expected_http_status: 200 + parameters: start_date=2099-01-10&end_date=2099-01-10&event_name=recording_started&country=US + expected_result: | + {"date":"2099-01-10","event_name":"recording_started","source":"client","platform":"desktop","app_version":"1.0.0","hostname":"","country":"US","device":"desktop","browser":"","os":"macOS","channel":"direct","plan_id":"","payment_status":"","subscription_status":"","currency":"","billing_interval":"","events":1,"actors":1,"users":1,"organizations":1,"revenue_minor":0} + +- name: product_events_daily_web_signup + description: Filters the daily rollup by source and platform. + expected_http_status: 200 + parameters: start_date=2099-01-10&end_date=2099-01-10&source=server&platform=web + expected_result: | + {"date":"2099-01-10","event_name":"user_signed_up","source":"server","platform":"web","app_version":"web","hostname":"cap.so","country":"US","device":"desktop","browser":"Chrome","os":"macOS","channel":"direct","plan_id":"","payment_status":"","subscription_status":"","currency":"","billing_interval":"","events":1,"actors":1,"users":1,"organizations":1,"revenue_minor":0} + +- name: product_events_daily_paid_purchase + description: Separates paid purchases from no-payment trials without scanning raw events. + expected_http_status: 200 + parameters: start_date=2099-01-11&end_date=2099-01-11&event_name=purchase_completed&payment_status=paid&subscription_status=active + expected_result: | + {"date":"2099-01-11","event_name":"purchase_completed","source":"server","platform":"web","app_version":"web","hostname":"cap.so","country":"US","device":"desktop","browser":"Chrome","os":"macOS","channel":"direct","plan_id":"","payment_status":"paid","subscription_status":"active","currency":"USD","billing_interval":"month","events":1,"actors":1,"users":1,"organizations":1,"revenue_minor":2500} + +- name: product_events_daily_trial + description: Keeps no-payment trials in their own purchase-status group. + expected_http_status: 200 + parameters: start_date=2099-01-11&end_date=2099-01-11&event_name=trial_started&subscription_status=trialing + expected_result: | + {"date":"2099-01-11","event_name":"trial_started","source":"server","platform":"web","app_version":"web","hostname":"cap.so","country":"US","device":"desktop","browser":"Chrome","os":"macOS","channel":"direct","plan_id":"","payment_status":"","subscription_status":"trialing","currency":"USD","billing_interval":"month","events":1,"actors":1,"users":1,"organizations":1,"revenue_minor":0} diff --git a/scripts/analytics/tinybird/tests/product_events_health.yaml b/scripts/analytics/tinybird/tests/product_events_health.yaml new file mode 100644 index 00000000000..c6aade634e1 --- /dev/null +++ b/scripts/analytics/tinybird/tests/product_events_health.yaml @@ -0,0 +1,6 @@ +- name: product_events_health_fixture_window + description: Reports retry and delivery quality for the fixture window. + expected_http_status: 200 + parameters: start_time=2099-01-10%2000%3A00%3A00.000&end_time=2099-01-12%2000%3A00%3A00.000 + expected_result: | + {"received_rows":13,"unique_events":13,"unique_payloads":13,"duplicate_rows":0,"payload_conflicts":0,"future_events":0,"late_events":0,"missing_identity_events":0,"ingestion_lag_ms":[100,100,100]} diff --git a/scripts/analytics/tinybird/tests/product_feature_adoption.yaml b/scripts/analytics/tinybird/tests/product_feature_adoption.yaml new file mode 100644 index 00000000000..5a89bfbc8ef --- /dev/null +++ b/scripts/analytics/tinybird/tests/product_feature_adoption.yaml @@ -0,0 +1,6 @@ +- name: product_feature_adoption_cross_platform + description: Counts one user and organization once per day across web and desktop. + expected_http_status: 200 + parameters: start_date=2099-01-12&end_date=2099-01-12&event_name=checkout_started + expected_result: | + {"event_name":"checkout_started","events":2,"actor_days":1,"user_days":1,"organization_days":1} diff --git a/scripts/analytics/tinybird/tests/product_traffic_overview.yaml b/scripts/analytics/tinybird/tests/product_traffic_overview.yaml new file mode 100644 index 00000000000..b6786275725 --- /dev/null +++ b/scripts/analytics/tinybird/tests/product_traffic_overview.yaml @@ -0,0 +1,6 @@ +- name: product_traffic_overview_fixture_sessions + description: Computes exact visitors, inactivity sessions, bounce, views, and foreground duration. + expected_http_status: 200 + parameters: start_date=2099-01-10&end_date=2099-01-10&hostname=cap.so + expected_result: | + {"date":"2099-01-10","visitors":2,"visits":2,"pageviews":3,"views_per_visit":1.5,"bounce_rate":50,"visit_duration_ms":11500,"engaged_ms":23000} diff --git a/scripts/analytics/tinybird/tests/product_traffic_pages.yaml b/scripts/analytics/tinybird/tests/product_traffic_pages.yaml new file mode 100644 index 00000000000..480cc5a59c3 --- /dev/null +++ b/scripts/analytics/tinybird/tests/product_traffic_pages.yaml @@ -0,0 +1,8 @@ +- name: product_traffic_pages_fixture + description: Returns page, landing, exit, time-on-page, and scroll metrics from pre-aggregated data. + expected_http_status: 200 + parameters: start_date=2099-01-10&end_date=2099-01-10&hostname=cap.so + expected_result: | + {"pathname":"/","visitors":1,"visits":1,"pageviews":1,"landings":1,"exits":1,"time_on_page_ms":5000,"average_scroll_depth":30} + {"pathname":"/download","visitors":1,"visits":1,"pageviews":1,"landings":0,"exits":1,"time_on_page_ms":3000,"average_scroll_depth":20} + {"pathname":"/pricing","visitors":1,"visits":1,"pageviews":1,"landings":1,"exits":0,"time_on_page_ms":15000,"average_scroll_depth":80} diff --git a/scripts/analytics/tinybird/tinybird.config.json b/scripts/analytics/tinybird/tinybird.config.json new file mode 100644 index 00000000000..acaa443ddf9 --- /dev/null +++ b/scripts/analytics/tinybird/tinybird.config.json @@ -0,0 +1,4 @@ +{ + "dev_mode": "manual", + "include": ["."] +} diff --git a/scripts/analytics/tooling.js b/scripts/analytics/tooling.js new file mode 100644 index 00000000000..838b577095a --- /dev/null +++ b/scripts/analytics/tooling.js @@ -0,0 +1,1258 @@ +import { spawnSync } from "node:child_process"; +import { createHash, createHmac } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { loadTinybirdProject } from "./datafiles.js"; + +const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(MODULE_DIR, "..", ".."); +const TINYBIRD_PROJECT_DIR = path.join(MODULE_DIR, "tinybird"); +const LOCAL_VERIFY_SCRIPT = path.join(MODULE_DIR, "verify-local.js"); +const LOCAL_FIXTURE_FILE = path.join( + TINYBIRD_PROJECT_DIR, + "fixtures", + "product_events_v1.local.ndjson", +); +const LOCAL_FIXTURE_DATES_FILE = path.join( + TINYBIRD_PROJECT_DIR, + "fixtures", + "local-dates.json", +); +const LOCAL_ENV_FILE = path.join(PROJECT_ROOT, ".env.analytics.local"); +const COMPOSE_FILE = path.join( + PROJECT_ROOT, + "packages", + "local-docker", + "docker-compose.yml", +); +const TEST_FILES = fs + .readdirSync(path.join(MODULE_DIR, "tests")) + .filter((fileName) => fileName.endsWith(".test.js")) + .sort() + .map((fileName) => path.join(MODULE_DIR, "tests", fileName)); +const CLOUD_URL_DEFAULT = "https://api.tinybird.co"; +const STAGING_WORKSPACE_ID = "37b8fef9-817f-4c3c-b21f-218c36a6077d"; +const LOCAL_COPY_RUN_ID = "run_local_copy_assertions"; +const EXCEPTIONAL_PRODUCT_COPY_PIPES = [ + "snapshot_product_event_id_states_v2", + "snapshot_product_event_day_states_v2", + "snapshot_product_events_canonical_v1", + "snapshot_product_events_daily_exact", + "snapshot_product_traffic_daily_exact", + "snapshot_product_traffic_pages_daily_exact", + "snapshot_product_activation_daily_exact", + "snapshot_product_creator_retention_exact", + "snapshot_product_identity_funnel_exact", + "snapshot_product_attribution_daily_exact", + "snapshot_product_experiment_outcomes_exact", + "snapshot_product_events_health_hourly", +]; +const BOUNDED_PRODUCT_COPY_PIPES = [ + "snapshot_product_events_daily_hot_v2", + "snapshot_product_events_daily_cold_v2", + "snapshot_product_traffic_daily_hot_v2", + "snapshot_product_traffic_daily_cold_v2", + "snapshot_product_traffic_pages_daily_hot_v2", + "snapshot_product_traffic_pages_daily_cold_v2", +]; +const PRODUCT_COPY_PIPES = [ + ...EXCEPTIONAL_PRODUCT_COPY_PIPES, + ...BOUNDED_PRODUCT_COPY_PIPES, + "publish_product_analytics_generation_v2", +]; +const LOCAL_PRODUCT_COPY_PIPES = [ + ...EXCEPTIONAL_PRODUCT_COPY_PIPES, + "snapshot_product_events_daily_hot_v2", + "snapshot_product_traffic_daily_hot_v2", + "snapshot_product_traffic_pages_daily_hot_v2", + "publish_product_analytics_generation_v2", +]; +const PRODUCT_COPY_TARGETS = [ + "product_event_id_states_v2", + "product_event_day_states_v2", + "product_events_canonical_v1", + "product_events_daily_exact", + "product_traffic_daily_exact", + "product_traffic_pages_daily_exact", + "product_activation_daily_exact", + "product_creator_retention_exact", + "product_identity_funnel_exact", + "product_attribution_daily_exact", + "product_experiment_outcomes_exact", + "product_events_health_hourly_exact", + "product_events_daily_hot_v2", + "product_events_daily_cold_v2", + "product_traffic_daily_hot_v2", + "product_traffic_daily_cold_v2", + "product_traffic_pages_daily_hot_v2", + "product_traffic_pages_daily_cold_v2", + "product_analytics_generations_v2", +]; +const PRODUCT_COPY_SOURCES = [ + "product_events_canonical_current", + "product_events_canonical_window", + "product_events_daily_bounded_v2", + "product_traffic_daily_bounded_v2", + "product_traffic_pages_daily_bounded_v2", +]; +const PRODUCT_DECISION_ENDPOINTS = [ + "product_activation", + "product_analytics_ci_assertions", + "product_analytics_copy_assertions", + "product_analytics_freshness", + "product_attribution", + "product_creator_activity", + "product_creator_retention", + "product_events_daily", + "product_events_health", + "product_experiment_outcomes", + "product_feature_adoption", + "product_identity_funnel", + "product_traffic_countries", + "product_traffic_overview", + "product_traffic_pages", + "product_traffic_sources", + "product_traffic_technology", + "product_traffic_totals", +]; +const WORKSPACE_ID_SOURCE = + "[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; +const WORKSPACE_ID_PATTERN = new RegExp(`^${WORKSPACE_ID_SOURCE}$`, "i"); +const LOCAL_TOKEN_SIGNING_KEY = "tinybird-local"; +const LOCAL_IDENTIFIERS = { + workspaceId: "00000000-0000-4000-8000-000000000001", + workspaceTokenId: "00000000-0000-4000-8000-000000000002", + userId: "00000000-0000-4000-8000-000000000003", + userTokenId: "00000000-0000-4000-8000-000000000004", +}; +const PRODUCT_COLUMNS = [ + ["event_id", "String"], + ["payload_hash", "FixedString(32)"], + ["occurred_at", "DateTime64(3)"], + ["received_at", "DateTime64(3)"], + ["event_name", "LowCardinality(String)"], + ["schema_version", "UInt16"], + ["source", "LowCardinality(String)"], + ["platform", "LowCardinality(String)"], + ["anonymous_id", "String"], + ["session_id", "String"], + ["user_id", "String"], + ["organization_id", "String"], + ["app_version", "LowCardinality(String)"], + ["pathname", "String"], + ["referrer", "String"], + ["country", "LowCardinality(String)"], + ["region", "LowCardinality(String)"], + ["city", "LowCardinality(String)"], + ["hostname", "LowCardinality(String)"], + ["browser", "LowCardinality(String)"], + ["device", "LowCardinality(String)"], + ["os", "LowCardinality(String)"], + ["channel", "LowCardinality(String)"], + ["traffic_class", "LowCardinality(String)"], + ["synthetic_run_id", "String"], + ["properties", "String"], +]; +const PRODUCT_SOURCES = new Set(["client", "server"]); +const PRODUCT_PLATFORMS = new Set([ + "web", + "desktop", + "mobile", + "cli", + "server", +]); +const SERVER_ONLY_EVENTS = new Set([ + "user_signed_up", + "checkout_started", + "guest_checkout_started", + "purchase_completed", + "trial_started", + "subscription_renewed", + "trial_converted", + "subscription_changed", + "subscription_cancelled", + "subscription_refunded", + "subscription_payment_failed", + "share_link_created", + "organization_invite_sent", + "organization_member_joined", + "collaboration_action_created", + "seat_quantity_changed", + "first_view_received", + "loom_import_started", + "loom_import_completed", + "loom_import_failed", +]); + +const composeArgs = (...args) => [ + "compose", + "--file", + COMPOSE_FILE, + "--profile", + "analytics", + ...args, +]; + +const localCliStep = (...args) => ({ + command: "docker", + args: composeArgs("run", "--rm", "tinybird-cli", ...args), + localAuth: true, +}); + +const cloudCliStep = (...args) => ({ + command: "docker", + args: composeArgs("run", "--rm", "tinybird-cloud-cli", ...args), + cloudAuth: true, +}); + +const operationPlan = (operation) => { + const localSourceCutoff = new Date() + .toISOString() + .replace("T", " ") + .replace("Z", ""); + const localGenerationId = `local_${localSourceCutoff.replaceAll(/[^0-9]/g, "")}`; + const localCopyStep = (name) => { + const parameters = [ + "--param", + "copy_max_threads=1", + "--param", + `copy_run_id=${LOCAL_COPY_RUN_ID}`, + ]; + if ( + BOUNDED_PRODUCT_COPY_PIPES.includes(name) || + name === "publish_product_analytics_generation_v2" + ) { + parameters.push( + "--param", + `source_cutoff=${localSourceCutoff}`, + "--param", + `generation_id=${localGenerationId}`, + ); + } + if (name === "publish_product_analytics_generation_v2") { + parameters.push("--param", "generation_kind=hot"); + } + return { + ...localCliStep("--local", "copy", "run", name, ...parameters, "--wait"), + attempts: 8, + retryPattern: /CANNOT_SCHEDULE_TASK|no free thread/i, + }; + }; + const plans = { + validate: [{ type: "validate" }], + test: [{ type: "validate" }, { type: "node-test" }], + "compose-check": [ + { type: "validate" }, + { command: "docker", args: composeArgs("config", "--quiet") }, + ], + local: [ + { type: "validate" }, + { type: "prepare-local-fixture" }, + { command: "docker", args: composeArgs("config", "--quiet") }, + { + command: "docker", + args: composeArgs( + "up", + "-d", + "--wait", + "--wait-timeout", + "120", + "tinybird-local", + ), + localAuth: true, + }, + { type: "build-local-with-decision-token" }, + ...PRODUCT_COPY_PIPES.map((name) => + localCliStep("--local", "copy", "pause", name), + ), + { type: "reset-local-fixture" }, + localCliStep( + "--local", + "datasource", + "append", + "product_events_v1", + "--file", + "fixtures/product_events_v1.local.ndjson", + ), + ...LOCAL_PRODUCT_COPY_PIPES.map(localCopyStep), + { type: "verify-local" }, + { type: "write-local-env" }, + ], + "local-test": [ + { type: "validate" }, + { type: "prepare-local-fixture" }, + { command: "docker", args: composeArgs("config", "--quiet") }, + { + command: "docker", + args: composeArgs( + "up", + "-d", + "--wait", + "--wait-timeout", + "120", + "tinybird-local", + ), + localAuth: true, + }, + ...PRODUCT_COPY_PIPES.map((name) => + localCliStep("--local", "copy", "pause", name), + ), + { type: "reset-local-fixture" }, + localCliStep( + "--local", + "datasource", + "append", + "product_events_v1", + "--file", + "fixtures/product_events_v1.local.ndjson", + ), + ...LOCAL_PRODUCT_COPY_PIPES.map(localCopyStep), + { type: "verify-local" }, + ], + "local-tokens": [{ type: "write-local-env" }], + "local-stop": [ + { + command: "docker", + args: composeArgs("stop", "tinybird-local"), + }, + ], + "cloud-check": [ + { type: "validate" }, + { type: "verify-cloud-workspace" }, + cloudCliStep("--cloud", "deploy", "--check"), + ], + "cloud-deploy": [ + { type: "validate" }, + { type: "verify-cloud-workspace" }, + cloudCliStep("--cloud", "deploy", "--check"), + cloudCliStep("--cloud", "deploy", "--wait"), + ], + }; + const plan = plans[operation]; + if (!plan) { + throw new Error( + `Unknown analytics command ${operation}. Expected one of: ${Object.keys(plans).join(", ")}.`, + ); + } + return plan; +}; + +const hasToken = (resource, name, scope) => + resource.tokens.some((token) => token.name === name && token.scope === scope); + +const tokenGrants = (project, tokenName) => [ + ...project.datasources.flatMap((datasource) => + datasource.tokens + .filter(({ name }) => name === tokenName) + .map(({ scope }) => `datasource:${datasource.name}:${scope}`), + ), + ...project.pipes.flatMap((pipe) => + pipe.tokens + .filter(({ name }) => name === tokenName) + .map(({ scope }) => `pipe:${pipe.name}:${scope}`), + ), +]; + +const validateExactTokenGrants = (project, tokenName, expected, issues) => { + const actual = new Set(tokenGrants(project, tokenName)); + for (const grant of expected) { + if (!actual.has(grant)) issues.push(`${tokenName} is missing ${grant}`); + } + for (const grant of actual) { + if (!expected.has(grant)) { + issues.push(`${tokenName} has unexpected ${grant}`); + } + } +}; + +const validateFixtures = (projectDir, issues) => { + const fixturePath = path.join( + projectDir, + "fixtures", + "product_events_v1.ndjson", + ); + if (!fs.existsSync(fixturePath)) { + issues.push("Missing product_events_v1 fixture data"); + return; + } + const eventPayloads = new Map(); + const rows = fs + .readFileSync(fixturePath, "utf8") + .split(/\r?\n/) + .filter(Boolean); + for (const [index, line] of rows.entries()) { + let event; + try { + event = JSON.parse(line); + } catch { + issues.push(`Fixture row ${index + 1} is not valid JSON`); + continue; + } + for (const [name] of PRODUCT_COLUMNS) { + if (!(name in event)) { + issues.push(`Fixture row ${index + 1} is missing ${name}`); + } + } + if (typeof event.event_id !== "string" || !event.event_id) { + issues.push(`Fixture row ${index + 1} has an invalid event_id`); + } else if ( + eventPayloads.has(event.event_id) && + eventPayloads.get(event.event_id) !== event.payload_hash + ) { + issues.push( + `Fixture event_id ${event.event_id} has conflicting payload hashes`, + ); + } else { + eventPayloads.set(event.event_id, event.payload_hash); + } + if (!/^[0-9a-f]{32}$/.test(event.payload_hash ?? "")) { + issues.push(`Fixture row ${index + 1} has an invalid payload_hash`); + } + if (!/^[a-z][a-z0-9_]*$/.test(event.event_name ?? "")) { + issues.push(`Fixture row ${index + 1} has an invalid event_name`); + } + if (!PRODUCT_SOURCES.has(event.source)) { + issues.push(`Fixture row ${index + 1} has an invalid source`); + } + if (!PRODUCT_PLATFORMS.has(event.platform)) { + issues.push(`Fixture row ${index + 1} has an invalid platform`); + } + if (SERVER_ONLY_EVENTS.has(event.event_name) && event.source !== "server") { + issues.push( + `Fixture row ${index + 1} uses a client source for a server-only event`, + ); + } + if (!event.user_id && !event.anonymous_id && !event.session_id) { + issues.push(`Fixture row ${index + 1} has no stable identity`); + } + if (typeof event.properties !== "string") { + issues.push(`Fixture row ${index + 1} properties must be a JSON string`); + } else { + try { + JSON.parse(event.properties); + } catch { + issues.push(`Fixture row ${index + 1} properties is not valid JSON`); + } + if (Buffer.byteLength(event.properties) > 16_384) { + issues.push(`Fixture row ${index + 1} properties exceeds 16 KiB`); + } + } + } +}; + +const validateAnalyticsProject = (projectDir = TINYBIRD_PROJECT_DIR) => { + const issues = []; + const configPath = path.join(projectDir, "tinybird.config.json"); + if (!fs.existsSync(configPath)) { + issues.push("Missing tinybird.config.json"); + } else { + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + if (config.dev_mode !== "manual") { + issues.push("tinybird.config.json must use dev_mode=manual"); + } + if (!Array.isArray(config.include) || !config.include.includes(".")) { + issues.push( + "tinybird.config.json must include the current project folder", + ); + } + } + + const project = loadTinybirdProject(projectDir); + validateExactTokenGrants( + project, + "product_events_agent_read", + new Set(), + issues, + ); + validateExactTokenGrants( + project, + "product_events_copy_runner", + new Set([ + ...PRODUCT_COPY_TARGETS.map((name) => `datasource:${name}:APPEND`), + ...PRODUCT_COPY_PIPES.map((name) => `pipe:${name}:READ`), + ...PRODUCT_COPY_SOURCES.map((name) => `pipe:${name}:READ`), + ]), + issues, + ); + validateExactTokenGrants( + project, + "product_events_erasure_lookup", + new Set([ + "datasource:product_events_v1:READ", + "datasource:product_events_canonical_v1:READ", + ]), + issues, + ); + const datasourceNames = new Set( + project.datasources.map((datasource) => datasource.name), + ); + const resourceNames = new Set([ + ...datasourceNames, + ...project.pipes.map((pipe) => pipe.name), + ]); + for (const pipe of project.pipes) { + if (datasourceNames.has(pipe.name)) { + issues.push(`Tinybird resource name ${pipe.name} is not unique`); + } + for (const nodeName of pipe.nodeNames) { + if (resourceNames.has(nodeName)) { + issues.push( + `Tinybird node ${nodeName} in ${pipe.name} conflicts with a resource name`, + ); + } + } + } + for (const name of [ + "analytics_events", + "analytics_pages_mv", + "analytics_sessions_mv", + ]) { + if (!project.datasources.some((datasource) => datasource.name === name)) { + issues.push(`Missing existing viewer datasource ${name}`); + } + } + + const product = project.datasources.find( + (datasource) => datasource.name === "product_events_v1", + ); + if (!product) { + issues.push("Missing product_events_v1 datasource"); + } else { + const actualColumns = product.columns.map(({ name, type }) => [name, type]); + if (JSON.stringify(actualColumns) !== JSON.stringify(PRODUCT_COLUMNS)) { + issues.push( + "product_events_v1 columns do not match the runtime contract", + ); + } + if (product.engine !== "MergeTree") { + issues.push("product_events_v1 must use append-optimized MergeTree"); + } + if (product.sortingKey !== "(received_at, event_id)") { + issues.push("product_events_v1 must sort by receipt time and event ID"); + } + if (product.versionColumn !== null) { + issues.push("product_events_v1 must preserve every delivery attempt"); + } + if (product.partitionKey !== "toYYYYMM(received_at)") { + issues.push("product_events_v1 must use monthly receipt-time partitions"); + } + if (product.ttl !== "toDateTime(received_at) + INTERVAL 800 DAY") { + issues.push("product_events_v1 must retain raw deliveries for 800 days"); + } + if (!hasToken(product, "product_events_ingest", "APPEND")) { + issues.push("product_events_v1 is missing its append-only token"); + } + if (!hasToken(product, "product_events_erasure_lookup", "READ")) { + issues.push("product_events_v1 is missing its erasure lookup token"); + } + if (hasToken(product, "product_events_agent_read", "READ")) { + issues.push( + "product_events_v1 must not expose raw identity data to agents", + ); + } + } + + const daily = project.datasources.find( + (datasource) => datasource.name === "product_events_daily_exact", + ); + if (!daily || daily.engine !== "AggregatingMergeTree") { + issues.push("Missing product_events_daily_exact snapshot datasource"); + } else { + if (hasToken(daily, "product_events_agent_read", "READ")) { + issues.push("Product event aggregate states must be endpoint-only"); + } + if (daily.ttl !== "date + INTERVAL 800 DAY") { + issues.push("Product event aggregates must retain 800 days"); + } + } + const canonical = project.datasources.find( + (datasource) => datasource.name === "product_events_canonical_v1", + ); + if (!canonical || canonical.engine !== "MergeTree") { + issues.push("Missing product_events_canonical_v1 datasource"); + } else { + if (hasToken(canonical, "product_events_agent_read", "READ")) { + issues.push("Canonical product events must not be readable by agents"); + } + if (canonical.ttl !== "toDateTime(received_at) + INTERVAL 800 DAY") { + issues.push("Canonical product events must retain 800 days"); + } + if (!hasToken(canonical, "product_events_erasure_lookup", "READ")) { + issues.push("Canonical product events are missing erasure lookup access"); + } + } + const eventStates = project.datasources.find( + (datasource) => datasource.name === "product_event_id_states_v2", + ); + if (!eventStates || eventStates.engine !== "AggregatingMergeTree") { + issues.push("Missing streaming product event ID aggregate state"); + } else { + if (eventStates.sortingKey !== "event_id") { + issues.push("Product event ID state must be sorted by event ID"); + } + if (eventStates.ttl !== "toDateTime(received_at) + INTERVAL 800 DAY") { + issues.push("Product event ID state must retain the erasure horizon"); + } + } + const eventStateMaterialization = project.pipes.find( + (pipe) => pipe.name === "materialize_product_event_id_states_v2", + ); + if ( + !eventStateMaterialization || + eventStateMaterialization.type !== "materialized" || + eventStateMaterialization.targetDatasource !== "product_event_id_states_v2" + ) { + issues.push("Missing streaming product event ID materialization"); + } + const eventDayStates = project.datasources.find( + (datasource) => datasource.name === "product_event_day_states_v2", + ); + if (!eventDayStates || eventDayStates.engine !== "AggregatingMergeTree") { + issues.push("Missing bounded product event day aggregate state"); + } else { + if (eventDayStates.sortingKey !== "(occurred_date, event_id)") { + issues.push( + "Product event day state must be sorted by date and event ID", + ); + } + if (eventDayStates.partitionKey !== "toYYYYMM(occurred_date)") { + issues.push( + "Product event day state must use monthly occurrence partitions", + ); + } + if (eventDayStates.ttl !== "occurred_date + INTERVAL 807 DAY") { + issues.push("Product event day state must retain the erasure horizon"); + } + } + const eventDayStateMaterialization = project.pipes.find( + (pipe) => pipe.name === "materialize_product_event_day_states_v2", + ); + if ( + !eventDayStateMaterialization || + eventDayStateMaterialization.type !== "materialized" || + eventDayStateMaterialization.targetDatasource !== + "product_event_day_states_v2" + ) { + issues.push("Missing bounded product event day materialization"); + } + for (const [prefix, hotTtl, coldTtl] of [ + [ + "product_events_daily", + "date + INTERVAL 16 DAY", + "date + INTERVAL 800 DAY", + ], + [ + "product_traffic_daily", + "date + INTERVAL 16 DAY", + "date + INTERVAL 800 DAY", + ], + [ + "product_traffic_pages_daily", + "date + INTERVAL 16 DAY", + "date + INTERVAL 800 DAY", + ], + ]) { + for (const [suffix, ttl] of [ + ["hot_v2", hotTtl], + ["cold_v2", coldTtl], + ]) { + const name = `${prefix}_${suffix}`; + const datasource = project.datasources.find( + (candidate) => candidate.name === name, + ); + if (!datasource || datasource.engine !== "AggregatingMergeTree") { + issues.push(`Missing bounded aggregate datasource ${name}`); + } else if (datasource.ttl !== ttl) { + issues.push(`${name} has an invalid retention window`); + } + } + if ( + !project.pipes.some( + (pipe) => + pipe.name === `${prefix}_current_v2` && pipe.type === "generic", + ) + ) { + issues.push( + `Missing generation-selected aggregate source ${prefix}_current_v2`, + ); + } + } + for (const name of [ + "product_events_daily_bounded_v2", + "product_traffic_daily_bounded_v2", + "product_traffic_pages_daily_bounded_v2", + ]) { + const pipe = project.pipes.find((candidate) => candidate.name === name); + if (!pipe || !hasToken(pipe, "product_events_copy_runner", "READ")) { + issues.push(`Missing bounded Copy source ${name}`); + } + } + const generations = project.datasources.find( + (datasource) => datasource.name === "product_analytics_generations_v2", + ); + if (!generations || generations.engine !== "MergeTree") { + issues.push("Missing bounded analytics publication barrier"); + } else if ( + generations.ttl !== "toDateTime(source_cutoff) + INTERVAL 807 DAY" + ) { + issues.push( + "Analytics generation metadata must retain the erasure horizon", + ); + } + for (const [name, engine] of [ + ["product_traffic_daily_exact", "AggregatingMergeTree"], + ["product_traffic_pages_daily_exact", "AggregatingMergeTree"], + ["product_activation_daily_exact", "AggregatingMergeTree"], + ["product_creator_retention_exact", "AggregatingMergeTree"], + ["product_identity_funnel_exact", "SummingMergeTree"], + ["product_attribution_daily_exact", "AggregatingMergeTree"], + ["product_experiment_outcomes_exact", "SummingMergeTree"], + ]) { + const datasource = project.datasources.find( + (candidate) => candidate.name === name, + ); + if (!datasource || datasource.engine !== engine) { + issues.push(`Missing privacy-safe aggregate datasource ${name}`); + } else { + if (hasToken(datasource, "product_events_agent_read", "READ")) { + issues.push(`${name} aggregate states must be endpoint-only`); + } + if (!datasource.ttl?.endsWith("+ INTERVAL 800 DAY")) { + issues.push(`${name} must retain 800 days`); + } + } + } + const healthHourly = project.datasources.find( + (datasource) => datasource.name === "product_events_health_hourly_exact", + ); + if (!healthHourly || healthHourly.engine !== "AggregatingMergeTree") { + issues.push( + "Missing product_events_health_hourly_exact aggregate datasource", + ); + } else if (hasToken(healthHourly, "product_events_agent_read", "READ")) { + issues.push("Product event health states must be endpoint-only"); + } + for (const name of PRODUCT_COPY_TARGETS) { + const datasource = project.datasources.find( + (candidate) => candidate.name === name, + ); + if ( + !datasource || + !hasToken(datasource, "product_events_copy_runner", "APPEND") + ) { + issues.push(`${name} is missing Copy runner append access`); + } + } + for (const datasource of project.datasources) { + if ( + !PRODUCT_COPY_TARGETS.includes(datasource.name) && + hasToken(datasource, "product_events_copy_runner", "APPEND") + ) { + issues.push(`${datasource.name} grants unexpected Copy runner access`); + } + if ( + !["product_events_v1", "product_events_canonical_v1"].includes( + datasource.name, + ) && + hasToken(datasource, "product_events_erasure_lookup", "READ") + ) { + issues.push(`${datasource.name} grants unexpected erasure lookup access`); + } + } + for (const name of PRODUCT_COPY_PIPES) { + const pipe = project.pipes.find((candidate) => candidate.name === name); + if (!pipe) { + issues.push(`Missing product analytics pipe ${name}`); + continue; + } + if (!hasToken(pipe, "product_events_copy_runner", "READ")) { + issues.push(`${name} is missing its execution-only Copy token`); + } + if (hasToken(pipe, "product_events_agent_read", "READ")) { + issues.push(`${name} must not grant Copy execution to the agent token`); + } + } + for (const name of PRODUCT_DECISION_ENDPOINTS) { + const pipe = project.pipes.find((candidate) => candidate.name === name); + if (!pipe) { + issues.push(`Missing product analytics pipe ${name}`); + continue; + } + if (pipe.tokens.length > 0) { + issues.push( + `${name} must use an expiring resource-scoped JWT instead of static token grants`, + ); + } + if (hasToken(pipe, "product_events_copy_runner", "READ")) { + issues.push(`${name} must not be queryable by the Copy runner token`); + } + } + + validateFixtures(projectDir, issues); + for (const testName of [ + "product_creator_retention.yaml", + "product_events_daily.yaml", + "product_events_health.yaml", + "product_feature_adoption.yaml", + "product_traffic_overview.yaml", + "product_traffic_pages.yaml", + ]) { + if (!fs.existsSync(path.join(projectDir, "tests", testName))) { + issues.push(`Missing Tinybird test ${testName}`); + } + } + return issues; +}; + +const cloudEnvironment = (env = process.env) => { + const token = env.TINYBIRD_DEPLOY_TOKEN?.trim(); + if (!token) { + throw new Error( + "TINYBIRD_DEPLOY_TOKEN is required and must have WORKSPACE:DEPLOY scope.", + ); + } + const workspaceId = env.TINYBIRD_WORKSPACE_ID?.trim(); + if (!workspaceId || !WORKSPACE_ID_PATTERN.test(workspaceId)) { + throw new Error( + "TINYBIRD_WORKSPACE_ID must be the staging workspace UUID.", + ); + } + if (workspaceId.toLowerCase() !== STAGING_WORKSPACE_ID) { + throw new Error( + "Analytics automation may deploy only to the staging workspace.", + ); + } + const host = + env.TINYBIRD_URL?.trim() || + env.PRODUCT_ANALYTICS_TINYBIRD_HOST?.trim() || + CLOUD_URL_DEFAULT; + return { + ...env, + TINYBIRD_TOKEN: token, + TINYBIRD_URL: host, + TB_TOKEN: token, + TB_HOST: host, + TINYBIRD_WORKSPACE_ID: workspaceId, + }; +}; + +const encodeLocalToken = (userId, tokenId) => { + const payload = JSON.stringify({ u: userId, id: tokenId, host: null }); + const encodedPayload = Buffer.from(payload).toString("base64url"); + const signature = createHmac("sha256", LOCAL_TOKEN_SIGNING_KEY) + .update(encodedPayload) + .digest("base64url"); + return `p.${encodedPayload}.${signature}`; +}; + +const localEnvironment = (env = process.env) => { + const workspaceToken = encodeLocalToken( + LOCAL_IDENTIFIERS.workspaceId, + LOCAL_IDENTIFIERS.workspaceTokenId, + ); + const userToken = encodeLocalToken( + LOCAL_IDENTIFIERS.userId, + LOCAL_IDENTIFIERS.userTokenId, + ); + const host = `http://127.0.0.1:${env.TINYBIRD_LOCAL_PORT || "7181"}`; + return { + ...env, + PRODUCT_ANALYTICS_TINYBIRD_HOST: host, + PRODUCT_ANALYTICS_TINYBIRD_TOKEN: workspaceToken, + TB_LOCAL_USER_TOKEN: userToken, + TB_LOCAL_WORKSPACE_TOKEN: workspaceToken, + }; +}; + +const parseLocalStaticToken = (output, tokenName) => { + const blocks = output.split(/^-{20,}\s*$/m); + for (const block of blocks) { + const name = block.match(/^name:\s*(.+)$/m)?.[1]?.trim(); + const token = block.match( + /^token:\s*(p\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/m, + )?.[1]; + if (name === tokenName && token) return token; + } + return undefined; +}; + +const listLocalStaticToken = (environment, tokenName) => { + const result = spawnSync( + "docker", + composeArgs( + "run", + "--rm", + "tinybird-cli", + "--local", + "token", + "ls", + "--match", + tokenName, + ), + { + cwd: PROJECT_ROOT, + encoding: "utf8", + env: environment, + maxBuffer: 1024 * 1024, + }, + ); + if (result.error || result.status !== 0) { + throw new Error(`Tinybird Local could not list static token ${tokenName}`); + } + const token = parseLocalStaticToken(result.stdout, tokenName); + if (!token) { + throw new Error(`Tinybird Local did not create static token ${tokenName}`); + } + return token; +}; + +const localResourceToken = async ( + environment, + tokenName, + fetcher = fetch, + tokenLister = listLocalStaticToken, +) => { + const statuses = []; + for (const token of [ + environment.TB_LOCAL_WORKSPACE_TOKEN, + environment.TB_LOCAL_USER_TOKEN, + ]) { + const response = await fetcher( + new URL("/v0/tokens", environment.PRODUCT_ANALYTICS_TINYBIRD_HOST), + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(15_000), + }, + ); + statuses.push(response.status); + if (!response.ok) continue; + const payload = await response.json(); + const resourceToken = Array.isArray(payload.tokens) + ? payload.tokens.find((candidate) => candidate.name === tokenName)?.token + : undefined; + if (typeof resourceToken === "string" && resourceToken.length >= 16) { + return resourceToken; + } + } + if (statuses.every((status) => status === 403)) { + return tokenLister(environment, tokenName); + } + throw new Error( + `Tinybird Local could not resolve ${tokenName}; token API statuses: ${statuses.join(", ")}`, + ); +}; + +const withLocalDecisionTokenDatafiles = async ( + projectDir, + operation, + endpoints = PRODUCT_DECISION_ENDPOINTS, +) => { + const originals = new Map(); + for (const endpoint of endpoints) { + const file = path.join(projectDir, "pipes", `${endpoint}.pipe`); + const contents = fs.readFileSync(file, "utf8"); + originals.set(file, contents); + fs.writeFileSync( + file, + `TOKEN product_events_local_verification READ\n${contents}`, + ); + } + try { + return await operation(); + } finally { + for (const [file, contents] of originals) { + fs.writeFileSync(file, contents); + } + } +}; + +const assertSafeStep = (step) => { + const command = [step.command, ...(step.args ?? [])].join(" "); + if ( + /allow-destructive|workspace\s+clear|datasource\s+(delete|truncate)/i.test( + command, + ) + ) { + throw new Error(`Refusing destructive analytics command: ${command}`); + } +}; + +const redactProcessOutput = (value) => + value + .replace(/p\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[REDACTED]") + .replace(/([?&]token=)[^&\s]+/gi, "$1[REDACTED]"); + +const runProcess = async (command, args, options = {}) => { + const { attempts = 1, retryPattern, ...spawnOptions } = options; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const result = spawnSync(command, args, { + cwd: PROJECT_ROOT, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + ...spawnOptions, + }); + if (result.stdout) process.stdout.write(redactProcessOutput(result.stdout)); + if (result.stderr) process.stderr.write(redactProcessOutput(result.stderr)); + if (!result.error && result.status === 0) return; + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if ( + attempt < attempts && + retryPattern instanceof RegExp && + retryPattern.test(output) + ) { + await new Promise((resolve) => setTimeout(resolve, attempt * 2_000)); + continue; + } + throw new Error( + result.error?.message ?? + `Command failed with exit code ${result.status}: ${command} ${args.join(" ")}`, + ); + } +}; + +const verifyCloudWorkspace = (env = process.env) => { + const environment = cloudEnvironment(env); + const tokenParts = environment.TINYBIRD_DEPLOY_TOKEN.split("."); + let tokenWorkspaceId; + try { + const tokenPayload = JSON.parse( + Buffer.from(tokenParts[1] ?? "", "base64url").toString("utf8"), + ); + tokenWorkspaceId = tokenPayload.u; + } catch { + throw new Error("Unable to parse Tinybird deploy token identity."); + } + if ( + tokenParts.length !== 3 || + typeof tokenWorkspaceId !== "string" || + !WORKSPACE_ID_PATTERN.test(tokenWorkspaceId) + ) { + throw new Error("Unable to parse Tinybird deploy token identity."); + } + if ( + tokenWorkspaceId.toLowerCase() !== + environment.TINYBIRD_WORKSPACE_ID.toLowerCase() + ) { + throw new Error( + "Tinybird deploy token does not target TINYBIRD_WORKSPACE_ID.", + ); + } + return environment.TINYBIRD_WORKSPACE_ID; +}; + +const writeLocalEnvironmentFile = ( + filePath = LOCAL_ENV_FILE, + environment = localEnvironment(), + runtimeToken, +) => { + if (typeof runtimeToken !== "string" || runtimeToken.length < 16) { + throw new Error("A scoped Tinybird Local runtime token is required"); + } + fs.writeFileSync( + filePath, + [ + `PRODUCT_ANALYTICS_TINYBIRD_HOST=${environment.PRODUCT_ANALYTICS_TINYBIRD_HOST}`, + `PRODUCT_ANALYTICS_TINYBIRD_TOKEN=${runtimeToken}`, + "", + ].join("\n"), + { mode: 0o600 }, + ); + return filePath; +}; + +const prepareLocalFixture = (now = new Date()) => { + const currentDay = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + ); + const dates = Object.fromEntries( + ["2099-01-10", "2099-01-11", "2099-01-12"].map((template, index) => [ + template, + new Date(currentDay - (2 - index) * 86_400_000) + .toISOString() + .slice(0, 10), + ]), + ); + const templateRows = fs + .readFileSync( + path.join(TINYBIRD_PROJECT_DIR, "fixtures", "product_events_v1.ndjson"), + "utf8", + ) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const fixtureSuffix = dates["2099-01-12"].replaceAll("-", ""); + const eventIds = new Map( + templateRows.map((row) => [ + row.event_id, + `${row.event_id}_${fixtureSuffix}`, + ]), + ); + const identityFields = [ + "anonymous_id", + "session_id", + "user_id", + "organization_id", + ]; + const identityIds = new Map( + templateRows.flatMap((row) => + identityFields + .map((field) => row[field]) + .filter(Boolean) + .map((value) => [value, `${value}_${fixtureSuffix}`]), + ), + ); + const fixtureRows = templateRows.map((templateRow) => { + const row = structuredClone(templateRow); + for (const [template, replacement] of Object.entries(dates)) { + row.occurred_at = row.occurred_at.replace(template, replacement); + row.received_at = row.received_at.replace(template, replacement); + } + row.event_id = eventIds.get(row.event_id); + for (const field of identityFields) { + if (identityIds.has(row[field])) { + row[field] = identityIds.get(row[field]); + } + } + const properties = JSON.parse(row.properties); + for (const [key, value] of Object.entries(properties)) { + if (typeof value === "string" && eventIds.has(value)) { + properties[key] = eventIds.get(value); + } else if (typeof value === "string" && identityIds.has(value)) { + properties[key] = identityIds.get(value); + } + } + row.properties = JSON.stringify(properties); + const hashPayload = { ...row }; + delete hashPayload.payload_hash; + row.payload_hash = createHash("sha256") + .update(JSON.stringify(hashPayload)) + .digest("hex") + .slice(0, 32); + return row; + }); + fs.writeFileSync( + LOCAL_FIXTURE_FILE, + `${fixtureRows.map((row) => JSON.stringify(row)).join("\n")}\n`, + ); + fs.writeFileSync(LOCAL_FIXTURE_DATES_FILE, `${JSON.stringify(dates)}\n`); + return { dates, rows: fixtureRows }; +}; + +const runAnalyticsCommand = async (operation) => { + for (const step of operationPlan(operation)) { + if (step.type === "validate") { + const issues = validateAnalyticsProject(); + if (issues.length > 0) { + throw new Error( + `Analytics project validation failed:\n- ${issues.join("\n- ")}`, + ); + } + console.log("Tinybird datafiles and fixtures are valid."); + continue; + } + if (step.type === "node-test") { + await runProcess(process.execPath, ["--test", ...TEST_FILES]); + continue; + } + if (step.type === "prepare-local-fixture") { + prepareLocalFixture(); + continue; + } + if (step.type === "build-local-with-decision-token") { + await withLocalDecisionTokenDatafiles(TINYBIRD_PROJECT_DIR, () => + runProcess( + "docker", + composeArgs("run", "--rm", "tinybird-cli", "--local", "build"), + { env: localEnvironment() }, + ), + ); + continue; + } + if (step.type === "reset-local-fixture") { + await runProcess( + "docker", + composeArgs( + "run", + "--rm", + "tinybird-cli", + "--local", + "datasource", + "truncate", + "product_events_v1", + "--yes", + ), + { env: localEnvironment() }, + ); + continue; + } + if (step.type === "verify-cloud-workspace") { + verifyCloudWorkspace(); + continue; + } + if (step.type === "write-local-env") { + const environment = localEnvironment(); + const runtimeToken = await localResourceToken( + environment, + "product_events_ingest", + ); + console.log( + `Wrote local analytics environment to ${writeLocalEnvironmentFile( + LOCAL_ENV_FILE, + environment, + runtimeToken, + )}`, + ); + continue; + } + if (step.type === "verify-local") { + const environment = localEnvironment(); + const readToken = await localResourceToken( + environment, + "product_events_local_verification", + ); + await runProcess(process.execPath, [LOCAL_VERIFY_SCRIPT], { + env: { + ...environment, + PRODUCT_ANALYTICS_TINYBIRD_TOKEN: readToken, + }, + }); + continue; + } + assertSafeStep(step); + await runProcess(step.command, step.args, { + attempts: step.attempts, + retryPattern: step.retryPattern, + env: step.cloudAuth + ? cloudEnvironment() + : step.localAuth + ? localEnvironment() + : process.env, + }); + } +}; + +export { + COMPOSE_FILE, + LOCAL_ENV_FILE, + PRODUCT_COLUMNS, + TINYBIRD_PROJECT_DIR, + assertSafeStep, + cloudEnvironment, + composeArgs, + localEnvironment, + localResourceToken, + operationPlan, + parseLocalStaticToken, + prepareLocalFixture, + redactProcessOutput, + runAnalyticsCommand, + validateAnalyticsProject, + verifyCloudWorkspace, + withLocalDecisionTokenDatafiles, + writeLocalEnvironmentFile, +}; diff --git a/scripts/analytics/verify-local.js b/scripts/analytics/verify-local.js new file mode 100644 index 00000000000..5873b9389a7 --- /dev/null +++ b/scripts/analytics/verify-local.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import process from "node:process"; + +const fixtureDates = JSON.parse( + fs.readFileSync( + new URL("tinybird/fixtures/local-dates.json", import.meta.url), + "utf8", + ), +); +const firstDate = fixtureDates["2099-01-10"]; +const secondDate = fixtureDates["2099-01-11"]; +const thirdDate = fixtureDates["2099-01-12"]; +if (!firstDate || !secondDate || !thirdDate) { + throw new Error("Local Tinybird fixture dates are missing"); +} + +const origin = process.env.PRODUCT_ANALYTICS_TINYBIRD_HOST; +const token = process.env.PRODUCT_ANALYTICS_TINYBIRD_TOKEN; +if (!origin || !token) { + throw new Error("Local Tinybird verification credentials are missing"); +} + +const query = async (pipe, parameters) => { + const url = new URL(`/v0/pipes/${pipe}.json`, origin); + for (const [name, value] of Object.entries(parameters)) { + url.searchParams.set(name, value); + } + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`${pipe} returned HTTP ${response.status}`); + } + const payload = await response.json(); + return payload.data ?? []; +}; + +const daily = await query("product_events_daily", { + start_date: firstDate, + end_date: firstDate, + event_name: "recording_started", + country: "US", +}); +assert.equal(daily.length, 1); +assert.equal(Number(daily[0].events), 1); +assert.equal(Number(daily[0].users), 1); +assert.equal(Number(daily[0].organizations), 1); + +const traffic = await query("product_traffic_overview", { + start_date: firstDate, + end_date: firstDate, + hostname: "cap.so", +}); +assert.equal(traffic.length, 1); +assert.equal(Number(traffic[0].visitors), 2); +assert.equal(Number(traffic[0].visits), 2); +assert.equal(Number(traffic[0].pageviews), 3); + +const pages = await query("product_traffic_pages", { + start_date: firstDate, + end_date: firstDate, + hostname: "cap.so", +}); +assert.deepEqual(pages.map((row) => row.pathname).sort(), [ + "/", + "/download", + "/pricing", +]); +assert.equal( + pages.reduce((total, row) => total + Number(row.pageviews), 0), + 3, +); + +const retention = await query("product_creator_retention", { + start_date: firstDate, + end_date: firstDate, +}); +const normalizedRetention = retention.map((row) => ({ + cohortDay: Number(row.cohort_day), + platform: row.platform, + creators: Number(row.creators), +})); +assert.deepEqual(normalizedRetention, [ + { cohortDay: 0, platform: "all", creators: 1 }, + { cohortDay: 1, platform: "all", creators: 1 }, +]); +assert.ok(normalizedRetention[1].creators <= normalizedRetention[0].creators); + +const featureAdoption = await query("product_feature_adoption", { + start_date: thirdDate, + end_date: thirdDate, + event_name: "checkout_started", +}); +assert.deepEqual( + featureAdoption.map((row) => ({ + eventName: row.event_name, + events: Number(row.events), + actorDays: Number(row.actor_days), + userDays: Number(row.user_days), + organizationDays: Number(row.organization_days), + })), + [ + { + eventName: "checkout_started", + events: 2, + actorDays: 1, + userDays: 1, + organizationDays: 1, + }, + ], +); + +const health = await query("product_events_health", { + start_time: `${firstDate} 00:00:00.000`, + end_time: `${thirdDate} 23:59:59.999`, +}); +assert.equal(health.length, 1); +assert.ok(Number(health[0].received_rows) >= 15); +assert.equal(Number(health[0].unique_events), 15); +assert.equal(Number(health[0].unique_payloads), 15); +assert.equal( + Number(health[0].duplicate_rows), + Number(health[0].received_rows) - 15, +); +assert.equal(Number(health[0].payload_conflicts), 0); + +const copyAssertions = await query("product_analytics_copy_assertions", { + copy_run_id: "run_local_copy_assertions", +}); +assert.deepEqual(copyAssertions, [ + { + decision_markers: 1, + traffic_markers: 1, + traffic_page_markers: 1, + activation_markers: 1, + retention_markers: 1, + identity_markers: 1, + attribution_markers: 1, + experiment_markers: 1, + health_markers: 1, + }, +]); + +console.log( + "Local Tinybird copies and typed endpoints match fixture semantics.", +);