diff --git a/cli/src/app/debug.ts b/cli/src/app/debug.ts index 43a76f39d9..45ff9c88b1 100644 --- a/cli/src/app/debug.ts +++ b/cli/src/app/debug.ts @@ -54,14 +54,15 @@ function describeFetchFailure(error: unknown, endpoint: string) { export type { AppDebugOptions as OptionsBaseDebug } from '../schemas/app' -export async function markSnag(channel: string, orgId: string, apikey: string, event: string, appId?: string, icon = 'โœ…') { +export async function markSnag(channel: string, orgId: string, apikey: string, event: string, appId?: string, icon = 'โœ…', tags: Record = {}) { + const allTags = { ...(appId ? { 'app-id': appId } : {}), ...tags } await sendEvent(apikey, { channel, event, icon, org_id: orgId, tracking_version: 2, - ...(appId ? { tags: { 'app-id': appId } } : {}), + ...(Object.keys(allTags).length > 0 ? { tags: allTags } : {}), notify: false, }) } diff --git a/cli/src/app/info.ts b/cli/src/app/info.ts index 93834119a8..a650649ab1 100644 --- a/cli/src/app/info.ts +++ b/cli/src/app/info.ts @@ -3,7 +3,7 @@ import { version as nodeVersion } from 'node:process' import { log, spinner } from '@clack/prompts' import pack from '../../package.json' import { trackEvent } from '../analytics/track' -import { getAllPackagesDependencies, getAppId, getBundleVersion, getConfig } from '../utils' +import { getAllPackagesDependencies, getAppId, getBundleVersion, getCapgoPluginTags, getConfig } from '../utils' import { getLatestVersion } from '../utils/latest-version' async function getLatestDependencies(installedDependencies: Record) { @@ -109,7 +109,10 @@ export async function getInfoInternal(options: DoctorInfoOptions, silent = false channel: 'cli-usage', event: 'Doctor Ran', icon: '๐Ÿ‘จโ€โš•๏ธ', - tags: computeDoctorAnalyticsTags(installedDependencies, latestDependencies), + tags: { + ...computeDoctorAnalyticsTags(installedDependencies, latestDependencies), + ...getCapgoPluginTags(options.packageJson), + }, }) if (JSON.stringify(installedDependencies) !== JSON.stringify(latestDependencies)) { diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 62fd689119..8e734d18d5 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -23,7 +23,7 @@ import { confirmWithRememberedChoice } from '../promptPreferences' import { showReplicationProgress } from '../replicationProgress' import { formatTable } from '../terminal-table' import { usesAlwaysDirectUpdate } from '../updaterConfig' -import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, hasOrganizationPerm, isCompatible, isDeprecatedPluginVersion, OrganizationPerm, regexSemver, resolveUserIdFromApiKey, sendEvent, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils' +import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCapgoPluginTags, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, hasOrganizationPerm, isCompatible, isDeprecatedPluginVersion, OrganizationPerm, regexSemver, resolveUserIdFromApiKey, sendEvent, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils' import { getVersionSuggestions, interactiveVersionBump } from '../versionHelpers' import { maybePromptBuilderCta, shouldBlockIncompatibleUpload } from './builder-cta' import { checkIndexPosition, searchInDirectory } from './check' @@ -1459,6 +1459,7 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl tags: { 'app-id': appid, 'bundle': bundle, + ...getCapgoPluginTags(options.packageJson), }, notify: false, }, options.verbose) @@ -1472,6 +1473,7 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl tags: { 'app-id': appid, 'bundle': bundle, + ...getCapgoPluginTags(options.packageJson), }, notify: false, notifyConsole: true, diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 9c0094d38f..84a13e0b74 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -31,7 +31,7 @@ import { copyToClipboard, revealInFinder } from '../support/clipboard' import { appendInternalLog, getInternalLogPath, startInternalLog } from '../support/internal-log' import { showReplicationProgress } from '../replicationProgress' import { formatRunnerCommand, splitRunnerCommand } from '../runner-command' -import { createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils' +import { createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getCapgoPluginTags, getConfig, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils' import { buildAppIdConflictSuggestions, isAppAlreadyExistsError } from './app-conflict' import { cancel as pCancel, confirm as pConfirm, intro as pIntro, isCancel as pIsCancel, log as pLog, outro as pOutro, select as pSelect, spinner as pSpinner, text as pText } from './prompts' import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime' @@ -552,7 +552,11 @@ async function runInitDoctorDiagnostics(): Promise { } async function exitCanceledInitOnboarding(orgId: string, apikey: string, message = 'You can resume the onboarding anytime by running the same command again'): Promise { - await markSnag('onboarding-v2', orgId, apikey, 'canceled', undefined, '๐Ÿคท') + await markSnag('onboarding-v2', orgId, apikey, 'canceled', undefined, '๐Ÿคท', { + last_step: lastStepName, + elapsed_ms: Date.now() - initStartedAt, + ...getCapgoPluginTags(globalPathToPackageJson), + }) pOutro(`Bye ๐Ÿ‘‹\n๐Ÿ’ก ${message}`) exit(1) } @@ -1371,8 +1375,27 @@ async function warnIfNotInCapacitorRoot() { } } +// Onboarding telemetry context: step timing plus the last reached step so the +// 'canceled' event can report where and how late users abort. +let initStartedAt = Date.now() +let lastStepStartedAt = initStartedAt +let lastStepName = 'init-start' + +function resetInitTelemetryContext() { + initStartedAt = Date.now() + lastStepStartedAt = initStartedAt + lastStepName = 'init-start' +} + async function markStep(orgId: string, apikey: string, step: string, appId: string) { - return markSnag('onboarding-v2', orgId, apikey, `onboarding-step-${step}`, appId) + const now = Date.now() + const elapsedMs = now - lastStepStartedAt + lastStepStartedAt = now + lastStepName = step + return markSnag('onboarding-v2', orgId, apikey, `onboarding-step-${step}`, appId, 'โœ…', { + elapsed_ms: elapsedMs, + ...getCapgoPluginTags(globalPathToPackageJson), + }) } /** @@ -4291,6 +4314,7 @@ async function maybeStarCapgoRepo(includeSkillsRepository = false, repository?: } export async function initApp(apikeyCommand: string, appId: string, options: SuperOptions) { + resetInitTelemetryContext() globalSupaHost = options.supaHost // honor --supa-host for the support-logs upload const pm = getPMAndCommand() // Start the verbose internal log early so it captures the whole run (incl. diff --git a/cli/src/utils.ts b/cli/src/utils.ts index edd719535f..b5d493d975 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -254,6 +254,57 @@ export function getBundleVersion(f: string = findRoot(cwd()), file: string | und return packageJson.version ?? '' } +// Cached so analytics call sites read package.json at most once per process. +let cachedCapgoPackages: string[] | undefined + +/** + * List the @capgo/* packages declared in the project's package.json files + * (dependencies + devDependencies), sorted alphabetically. + * Used for telemetry only: never throws, returns [] when nothing is readable. + * The first call wins the cache, later calls reuse it regardless of path. + */ +export function listCapgoPackages(packageJsonPath?: string): string[] { + if (cachedCapgoPackages) + return cachedCapgoPackages + const found = new Set() + try { + const files = packageJsonPath + ? packageJsonPath.split(',').map(file => file.trim()).filter(Boolean) + : [join(findRoot(cwd()), PACKNAME)] + for (const file of files) { + try { + if (!existsSync(file)) + continue + const pkg = JSON.parse(readFileSync(file, 'utf-8')) + const dependencies = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})] + for (const dependency of dependencies) { + if (dependency.startsWith('@capgo/')) + found.add(dependency) + } + } + catch { + // Unreadable or invalid package.json: skip it, telemetry must not break commands. + } + } + } + catch { + // Root resolution failed: report no plugins instead of throwing. + } + cachedCapgoPackages = [...found].sort() + return cachedCapgoPackages +} + +/** + * Plugin tags shared by key analytics events (init steps, doctor, upload). + */ +export function getCapgoPluginTags(packageJsonPath?: string): { capgo_plugins: string, capgo_plugin_count: number } { + const plugins = listCapgoPackages(packageJsonPath) + return { + capgo_plugins: plugins.join(','), + capgo_plugin_count: plugins.length, + } +} + function returnVersion(version: string) { const tmpVersion = version.replace('^', '').replace('~', '') if (canParse(tmpVersion)) { diff --git a/cli/test/test-doctor-analytics.mjs b/cli/test/test-doctor-analytics.mjs index 1b54102ef4..c319dd7fcb 100644 --- a/cli/test/test-doctor-analytics.mjs +++ b/cli/test/test-doctor-analytics.mjs @@ -1,6 +1,10 @@ #!/usr/bin/env node import assert from 'node:assert/strict' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { computeDoctorAnalyticsTags } from '../src/app/info.ts' +import { getCapgoPluginTags, listCapgoPackages } from '../src/utils.ts' console.log('๐Ÿงช Testing doctor analytics tags...\n') @@ -31,4 +35,28 @@ assert.equal(allOutdated.is_outdated, true) assert.equal(allOutdated.dependency_count, 2) assert.equal(allOutdated.outdated_count, 2) +// --- capgo plugin tags helper (shared by init/doctor/upload events) --- +const dir = mkdtempSync(join(tmpdir(), 'capgo-plugin-tags-')) +const pkgPath = join(dir, 'package.json') +writeFileSync(pkgPath, JSON.stringify({ + dependencies: { + '@capgo/capacitor-updater': '^7.0.0', + '@capacitor/core': '^7.0.0', + }, + devDependencies: { + '@capgo/cli': '^7.0.0', + '@capgo/capacitor-social-login': '^1.0.0', + }, +})) + +const plugins = listCapgoPackages(pkgPath) +assert.deepEqual(plugins, ['@capgo/capacitor-social-login', '@capgo/capacitor-updater', '@capgo/cli'], 'deps + devDeps, sorted, @capgo/* only') + +const pluginTags = getCapgoPluginTags(pkgPath) +assert.equal(pluginTags.capgo_plugins, '@capgo/capacitor-social-login,@capgo/capacitor-updater,@capgo/cli') +assert.equal(pluginTags.capgo_plugin_count, 3) + +// The result is cached per process: later calls reuse it whatever the path. +assert.deepEqual(listCapgoPackages('/nonexistent/package.json'), plugins) + console.log('โœ… doctor analytics tags tests passed') diff --git a/cli/test/test-v2-event-migration.mjs b/cli/test/test-v2-event-migration.mjs index a5896c0477..d0351ef706 100644 --- a/cli/test/test-v2-event-migration.mjs +++ b/cli/test/test-v2-event-migration.mjs @@ -35,6 +35,19 @@ try { assert.equal(body.user_id, undefined, 'CLI must not send user_id (backend derives the actor from the key)') assert.deepEqual(body.tags, { 'app-id': 'com.example.app' }) + // The optional tags parameter merges caller tags with the app-id tag. + await markSnag('onboarding-v2', 'org-123', 'capgo-key', 'canceled', undefined, '๐Ÿคท', { + last_step: 'add-app', + elapsed_ms: 1234, + }) + + const eventRequests = requests.filter(request => request.url.endsWith('/private/events')) + assert.equal(eventRequests.length, 2, 'Expected one request per markSnag call') + const canceledBody = JSON.parse(eventRequests[1].init.body) + assert.equal(canceledBody.event, 'canceled') + assert.equal(canceledBody.icon, '๐Ÿคท') + assert.deepEqual(canceledBody.tags, { last_step: 'add-app', elapsed_ms: 1234 }) + console.log('โœ… v2 event migration tests passed') } finally { diff --git a/read_replicate/schema_replicate.sql b/read_replicate/schema_replicate.sql index 97d09a7dca..ab5c7db445 100644 --- a/read_replicate/schema_replicate.sql +++ b/read_replicate/schema_replicate.sql @@ -117,6 +117,7 @@ CREATE TABLE public.apps ( stats_refresh_requested_at timestamp without time zone, build_timeout_seconds bigint DEFAULT 900 NOT NULL, build_timeout_updated_at timestamp with time zone DEFAULT now() NOT NULL, + first_update_delivered_at timestamp with time zone, CONSTRAINT apps_build_timeout_seconds_check CHECK (((build_timeout_seconds >= 300) AND (build_timeout_seconds <= 21600))) ); diff --git a/src/components/dashboard/StepsApp.vue b/src/components/dashboard/StepsApp.vue index 36f12a7351..25c3f63147 100644 --- a/src/components/dashboard/StepsApp.vue +++ b/src/components/dashboard/StepsApp.vue @@ -8,6 +8,7 @@ import IconChevronDown from '~icons/lucide/chevron-down' import IconLoader from '~icons/lucide/loader-2' import InviteTeammateModal from '~/components/dashboard/InviteTeammateModal.vue' import { createDefaultApiKey, findUsablePlainApiKey } from '~/services/apikeys' +import { stepElapsed } from '~/services/onboardingTimer' import { pushEvent } from '~/services/posthog' import { getLocalConfig, isLocal, useSupabase } from '~/services/supabase' import { sendEvent } from '~/services/tracking' @@ -93,6 +94,7 @@ function setLog() { org_id: orgId, tracking_version: 2, notify: false, + tags: { step_elapsed_ms: stepElapsed() }, }).catch() pushEvent(`user:onboarding-step-${stepToName(step.value)}`, config.supaHost, { org_id: orgId }) } diff --git a/src/components/dashboard/StepsBuild.vue b/src/components/dashboard/StepsBuild.vue index 8e848abb5b..054734de29 100644 --- a/src/components/dashboard/StepsBuild.vue +++ b/src/components/dashboard/StepsBuild.vue @@ -14,6 +14,7 @@ import IconTerminal from '~icons/lucide/terminal-square' import IconAndroid from '~icons/mdi/android' import IconApple from '~icons/mdi/apple' import { createDefaultApiKey, findUsablePlainApiKey } from '~/services/apikeys' +import { stepElapsed } from '~/services/onboardingTimer' import { pushEvent } from '~/services/posthog' import { getLocalConfig, isLocal, useSupabase } from '~/services/supabase' import { sendEvent } from '~/services/tracking' @@ -150,6 +151,7 @@ function setLog() { org_id: orgId, tracking_version: 2, notify: false, + tags: { step_elapsed_ms: stepElapsed() }, }).catch() pushEvent(`user:onboarding-build-${stepToName(step.value)}`, config.supaHost, { org_id: orgId }) } diff --git a/src/components/dashboard/StepsBundle.vue b/src/components/dashboard/StepsBundle.vue index 52c71e6500..2af6f9b229 100644 --- a/src/components/dashboard/StepsBundle.vue +++ b/src/components/dashboard/StepsBundle.vue @@ -6,6 +6,7 @@ import arrowBack from '~icons/ion/arrow-back?width=2em&height=2em' import IconLoader from '~icons/lucide/loader-2' import InviteTeammateModal from '~/components/dashboard/InviteTeammateModal.vue' import { createDefaultApiKey, findUsablePlainApiKey } from '~/services/apikeys' +import { stepElapsed } from '~/services/onboardingTimer' import { pushEvent } from '~/services/posthog' import { getLocalConfig, isLocal, useSupabase } from '~/services/supabase' import { sendEvent } from '~/services/tracking' @@ -80,6 +81,7 @@ function setLog() { org_id: orgId, tracking_version: 2, notify: false, + tags: { step_elapsed_ms: stepElapsed() }, }).catch() pushEvent(`user:onboarding-bundle-${stepToName(step.value)}`, config.supaHost, { org_id: orgId }) } diff --git a/src/main.ts b/src/main.ts index a5e1f0bbc8..4ed343d3cd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { setupLayouts } from 'virtual:generated-layouts' import { createApp } from 'vue' import { createRouter, createWebHistory } from 'vue-router' import { routes } from 'vue-router/auto-routes' +import { captureFirstTouch } from '~/services/attribution' import { installDeepLinkHandler } from '~/services/deepLinks' import { getNativeExternalPurchaseRedirect, isNativeAppStoreContext, isNativeExternalPurchaseRestrictedPath } from '~/services/nativeCompliance' import { posthogLoader } from '~/services/posthog' @@ -192,6 +193,8 @@ router.beforeEach((to, from, next) => { const config = getLocalConfig() posthogLoader(config.supaHost) +// Capture first-touch attribution (UTM params, referrer) before any navigation strips them +captureFirstTouch() // install all modules under `modules/` type UserModule = (ctx: { app: typeof app, router: Router }) => void diff --git a/src/pages/login.vue b/src/pages/login.vue index c817007f6d..ec43da3ee2 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -15,11 +15,13 @@ import iconEmail from '~icons/oui/email?raw' import iconPassword from '~icons/ph/key?raw' import mfaIcon from '~icons/simple-icons/2fas?raw' import { hideLoader } from '~/services/loader' -import { autoAuth, defaultApiHost, hashEmail, useSupabase } from '~/services/supabase' +import { pushEvent } from '~/services/posthog' +import { autoAuth, defaultApiHost, getLocalConfig, hashEmail, useSupabase } from '~/services/supabase' import { openSupport } from '~/services/support' const route = useRoute('/login') const supabase = useSupabase() +const config = getLocalConfig() const isLoading = ref(false) const isMobile = ref(Capacitor.isNativePlatform()) const turnstileToken = ref('') @@ -296,6 +298,11 @@ async function login(form: { email: string, password: string }) { isLoading.value = false console.error('error', error) setErrors('login-account', [error.message], {}) + // Coarse failure reason for analytics; never include credentials + const reason = error.message.includes('Invalid login credentials') + ? 'invalid_credentials' + : error.message.includes('captcha') ? 'captcha' : 'unknown' + pushEvent('user:login-failed', config.supaHost, { reason }) if (error.message.includes('Invalid login credentials')) { turnstileToken.value = '' captchaComponent.value?.reset() @@ -389,6 +396,7 @@ async function handleSsoLogin() { if (error) { console.error('SSO login error', error) + pushEvent('user:login-failed', config.supaHost, { reason: error.message.includes('captcha') ? 'captcha' : 'sso_failed' }) turnstileToken.value = '' captchaComponent.value?.reset() if (error.message.includes('captcha')) { @@ -407,6 +415,7 @@ async function handleSsoLogin() { } catch (err) { console.error('SSO login error', err) + pushEvent('user:login-failed', config.supaHost, { reason: 'sso_failed' }) turnstileToken.value = '' captchaComponent.value?.reset() toast.error(t('invalid-auth')) @@ -423,6 +432,7 @@ async function handleMfaSubmit(form: { code: string }) { }) if (verify.error) { + pushEvent('user:login-failed', config.supaHost, { reason: 'mfa_failed' }) toast.error(t('invalid-mfa-code')) console.error('verify error', verify.error) isLoading.value = false diff --git a/src/pages/register.vue b/src/pages/register.vue index 7a4dfd0662..a9dac6fc03 100644 --- a/src/pages/register.vue +++ b/src/pages/register.vue @@ -9,11 +9,13 @@ import iconEmail from '~icons/oui/email?raw' import iconPassword from '~icons/ph/key?raw' import iconName from '~icons/ph/user?raw' import { authGhostButtonClass, authInlineLinkClass, authPanelClass, authPrimaryButtonClass } from '~/components/auth/pageStyles' -import { hashEmail, useSupabase } from '~/services/supabase' +import { pushEvent } from '~/services/posthog' +import { getLocalConfig, hashEmail, useSupabase } from '~/services/supabase' import { openSupport } from '~/services/support' const router = useRouter() const supabase = useSupabase() +const config = getLocalConfig() const { t } = useI18n() const turnstileToken = ref('') const captchaKey = ref(import.meta.env.VITE_CAPTCHA_KEY) @@ -34,6 +36,7 @@ async function submit(form: { first_name: string, last_name: string, password: s if (errorDeleted) console.error(errorDeleted) if (!deleted) { + pushEvent('user:signup-failed', config.supaHost, { reason: 'email_previously_used' }) setErrors('register-account', [t('used-to-create')], {}) return } @@ -52,6 +55,14 @@ async function submit(form: { first_name: string, last_name: string, password: s ) isLoading.value = false if (error || !user) { + // Coarse failure category for analytics; never include the raw error message + const message = (error?.message ?? '').toLowerCase() + const reason = message.includes('captcha') + ? 'captcha' + : message.includes('already') + ? 'already_registered' + : message.includes('password') ? 'weak_password' : 'unknown' + pushEvent('user:signup-failed', config.supaHost, { reason }) setErrors('register-account', [error?.message || 'user not found'], {}) return } @@ -73,6 +84,7 @@ async function submit(form: { first_name: string, last_name: string, password: s console.error('Failed to seed user profile after signup', profileError) } + pushEvent('user:signup-completed', config.supaHost, { method: 'email' }) router.push('/dashboard') } diff --git a/src/services/attribution.ts b/src/services/attribution.ts new file mode 100644 index 0000000000..c6a950390a --- /dev/null +++ b/src/services/attribution.ts @@ -0,0 +1,81 @@ +// First-touch attribution capture. +// Stores the earliest marketing signals (UTM params, click ids, referrer, landing URL) +// in localStorage so they can later be attached to the PostHog person with +// $set_once semantics (see setUser in ~/services/posthog). + +const STORAGE_KEY = 'capgo_first_touch' +const SIGNAL_PARAMS = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'ref', + 'gclid', + 'fbclid', +] as const + +type SignalParam = typeof SIGNAL_PARAMS[number] + +export type FirstTouch = { + captured_at: string + landing_url: string + referrer: string +} & Partial> + +function isExternalReferrer(referrer: string): boolean { + if (!referrer) + return false + try { + return new URL(referrer).origin !== window.location.origin + } + catch { + return false + } +} + +export function captureFirstTouch(): void { + try { + if (localStorage.getItem(STORAGE_KEY)) + return + + const search = new URLSearchParams(window.location.search) + const params: Partial> = {} + for (const key of SIGNAL_PARAMS) { + const value = search.get(key) + if (value) + params[key] = value + } + + const referrer = document.referrer + const hasSignal = Object.keys(params).length > 0 || isExternalReferrer(referrer) + if (!hasSignal) + return + + const firstTouch: FirstTouch = { + captured_at: new Date().toISOString(), + landing_url: window.location.href, + referrer, + ...params, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(firstTouch)) + } + catch (error) { + console.error('Cannot capture first-touch attribution', error) + } +} + +export function getFirstTouch(): FirstTouch | null { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) + return null + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') + return null + return parsed as FirstTouch + } + catch { + return null + } +} diff --git a/src/services/onboardingTimer.ts b/src/services/onboardingTimer.ts new file mode 100644 index 0000000000..6cd5030c64 --- /dev/null +++ b/src/services/onboardingTimer.ts @@ -0,0 +1,16 @@ +// Measures the time users spend between onboarding step events. +// The mark is module-level so it persists across onboarding components +// for the lifetime of the page session. + +let lastMark: number | null = null + +/** + * Returns milliseconds elapsed since the previous call and resets the mark. + * The first call of the session returns 0. + */ +export function stepElapsed(): number { + const now = Date.now() + const elapsedMs = lastMark === null ? 0 : now - lastMark + lastMark = now + return elapsedMs +} diff --git a/src/services/posthog.ts b/src/services/posthog.ts index 70f3d1115b..9faad845b1 100644 --- a/src/services/posthog.ts +++ b/src/services/posthog.ts @@ -1,4 +1,5 @@ // @ts-nocheck +import { getFirstTouch } from '~/services/attribution' import { shouldSuppressPostHogExceptionEvent } from '~/services/staleAssetErrors' import { isLocal } from '~/services/supabase' @@ -69,6 +70,13 @@ export function pushEvent(nameEvent: string, supaHost: string, properties?: Post posthog.capture(nameEvent, properties) } +export function setOrganization(orgId: string, supaHost: string): void { + if (isLocal(supaHost)) + return + // Attach the organization group to every subsequent browser event + posthog.group('organization', orgId) +} + export function setUser(uuid: string, data: { nickname?: string phone?: string @@ -85,6 +93,20 @@ export function setUser(uuid: string, data: { posthog.setPersonProperties( { avatar: data.avatar }, ) + const firstTouch = getFirstTouch() + if (firstTouch) { + // setPersonProperties(setProps, setOnceProps): pass the first-touch map + // as $set_once so it never overwrites previously captured attribution + posthog.setPersonProperties({}, { + first_touch_landing_url: firstTouch.landing_url, + first_touch_referrer: firstTouch.referrer, + first_touch_utm_source: firstTouch.utm_source, + first_touch_utm_medium: firstTouch.utm_medium, + first_touch_utm_campaign: firstTouch.utm_campaign, + first_touch_ref: firstTouch.ref, + first_touch_captured_at: firstTouch.captured_at, + }) + } } export function reset(supaHost: string): void { diff --git a/src/stores/organization.ts b/src/stores/organization.ts index 5ca8eb81d4..58fbc660a7 100644 --- a/src/stores/organization.ts +++ b/src/stores/organization.ts @@ -3,8 +3,9 @@ import type { ArrayElement, Concrete, Merge } from '~/services/types' import type { Database } from '~/types/supabase.types' import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' +import { setOrganization } from '~/services/posthog' import { createSignedImageUrl, getImmediateImageUrl, resolveImagePath } from '~/services/storage' -import { isPlatformAdmin, stripeEnabled, useSupabase } from '~/services/supabase' +import { getLocalConfig, isPlatformAdmin, stripeEnabled, useSupabase } from '~/services/supabase' import { clearWebsitePaidUserCookie, setWebsitePaidUserCookie, syncWebsitePaidUserCookieFromOrganizations } from '~/services/websiteAuthCookie' import { createDeferredPromise } from '../utils/promise' import { useDashboardAppsStore } from './dashboardApps' @@ -299,6 +300,8 @@ export const useOrganizationStore = defineStore('organization', () => { } localStorage.setItem(STORAGE_KEY, currentOrganizationRaw.gid) + // Tag all subsequent PostHog browser events with the active organization group + setOrganization(currentOrganizationRaw.gid, getLocalConfig().supaHost) currentRole.value = await getCurrentRole(currentOrganizationRaw.created_by) // Don't mark as failed if user lacks 2FA or password access - the data is redacted and unreliable const lacks2FAAccess = currentOrganizationRaw.enforcing_2fa === true && currentOrganizationRaw['2fa_has_access'] === false diff --git a/supabase/functions/_backend/triggers/logsnag_insights.ts b/supabase/functions/_backend/triggers/logsnag_insights.ts index d6fb8f4629..0e12d0ea8a 100644 --- a/supabase/functions/_backend/triggers/logsnag_insights.ts +++ b/supabase/functions/_backend/triggers/logsnag_insights.ts @@ -841,6 +841,81 @@ async function countDemoSeededApps(c: Context, createdAfterIso: string): Promise } } +interface AppActivationRow extends Record { + app_id: string + owner_org: string + app_created_at: string + org_created_by: string | null + activation_date: string +} + +// Detect apps that just delivered their first update to a device and emit a +// one-time 'First Device Update Delivered' activation event per app. +// Idempotent via apps.first_update_delivered_at (claimed with RETURNING). +async function trackFirstUpdateDelivered(c: Context) { + const pgClient = getPgClient(c, false) + const drizzleClient = getDrizzleClient(pgClient) + try { + const result = await drizzleClient.execute(sql` + SELECT + apps.app_id, + apps.owner_org, + apps.created_at AS app_created_at, + orgs.created_by AS org_created_by, + MIN(dv.date)::date AS activation_date + FROM public.apps AS apps + INNER JOIN public.daily_version AS dv ON dv.app_id = apps.app_id + LEFT JOIN public.orgs AS orgs ON orgs.id = apps.owner_org + WHERE apps.first_update_delivered_at IS NULL + AND dv.install > 0 + GROUP BY apps.app_id, apps.owner_org, apps.created_at, orgs.created_by + LIMIT 200 + `) + + for (const row of result.rows ?? []) { + const activationDate = new Date(row.activation_date) + const claimed = await drizzleClient.execute<{ app_id: string }>(sql` + UPDATE public.apps + SET first_update_delivered_at = ${activationDate} + WHERE app_id = ${row.app_id} + AND first_update_delivered_at IS NULL + RETURNING app_id + `) + // Already claimed by a concurrent run; skip to keep the event one-time. + if (!(claimed.rows ?? []).length) + continue + + // Backlog drain guard: historical apps (activated long before this column + // existed) get the flag set silently; only fresh activations emit events. + const daysSinceActivation = Math.floor((Date.now() - activationDate.getTime()) / (24 * 60 * 60 * 1000)) + if (daysSinceActivation > 7) + continue + + const daysSinceAppCreated = Math.max(0, Math.floor((activationDate.getTime() - new Date(row.app_created_at).getTime()) / (24 * 60 * 60 * 1000))) + await sendEventToTracking(c, { + channel: 'app-activation', + event: 'First Device Update Delivered', + icon: '๐Ÿš€', + user_id: row.org_created_by ?? row.owner_org, + groups: { organization: row.owner_org }, + tags: { + app_id: row.app_id, + owner_org: row.owner_org, + days_since_app_created: daysSinceAppCreated, + }, + setPersonProperties: false, + notify: false, + }, { background: false }) + } + } + catch (error) { + cloudlogErr({ requestId: c.get('requestId'), message: 'trackFirstUpdateDelivered error', error }) + } + finally { + closeClient(c, pgClient) + } +} + function getStats(c: Context, window?: DailyWindow): GlobalStats { const supabase = supabaseAdmin(c) const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() @@ -1454,6 +1529,10 @@ app.post('/', middlewareAPISecret, async (c) => { }) cloudlog({ requestId: c.get('requestId'), message: 'Sent to logsnag done' }) + // Activation events run last; trackFirstUpdateDelivered swallows its own + // errors so a failure never breaks the daily stats snapshot above. + await trackFirstUpdateDelivered(c) + // Note: Device cleanup is no longer needed as Analytics Engine handles data retention automatically return c.json(BRES) diff --git a/supabase/functions/_backend/triggers/on_app_create.ts b/supabase/functions/_backend/triggers/on_app_create.ts index 42aeeb7632..56e4e87160 100644 --- a/supabase/functions/_backend/triggers/on_app_create.ts +++ b/supabase/functions/_backend/triggers/on_app_create.ts @@ -33,14 +33,17 @@ app.post('/', middlewareAPISecret, triggerValidator('apps', 'INSERT'), async (c) const drizzleClient = getDrizzleClient(pg) let appExists = false let ownerOrg: string | undefined + let orgCreatedBy: string | undefined try { const rows = await drizzleClient - .select({ owner_org: schema.apps.owner_org }) + .select({ owner_org: schema.apps.owner_org, org_created_by: schema.orgs.created_by }) .from(schema.apps) + .leftJoin(schema.orgs, eq(schema.orgs.id, schema.apps.owner_org)) .where(or(eq(schema.apps.id, record.id), eq(schema.apps.app_id, record.app_id))) .limit(1) appExists = rows.length > 0 ownerOrg = rows[0]?.owner_org ?? undefined + orgCreatedBy = rows[0]?.org_created_by ?? undefined } catch (error) { cloudlog({ requestId: c.get('requestId'), message: 'Error fetching app owner_org', error, appId: record.id, app_id: record.app_id }) @@ -115,6 +118,8 @@ app.post('/', middlewareAPISecret, triggerValidator('apps', 'INSERT'), async (c) throw simpleError('error_fetching_organization', 'Error fetching organization', { error }) } + orgCreatedBy = orgCreatedBy ?? (data.created_by ?? undefined) + return { cron: '* * * * *', event: 'app:created', @@ -135,10 +140,13 @@ app.post('/', middlewareAPISecret, triggerValidator('apps', 'INSERT'), async (c) event: isDemo ? 'Demo App Created' : isPendingOnboarding ? 'Onboarding App Created' : 'App Created', icon: isDemo ? '๐ŸŽฎ' : isPendingOnboarding ? '๐Ÿงญ' : '๐ŸŽ‰', sentToBento: Boolean(appCreatedBentoEvent), - user_id: ownerOrg, + // PostHog person must be the org creator, not the org id, so signup funnels + // can join this event with real user identities. Org id stays in groups/tags. + user_id: orgCreatedBy ?? ownerOrg, groups: { organization: ownerOrg }, tags: { app_id: record.app_id, + owner_org: ownerOrg, is_demo: isDemo ? 'true' : 'false', need_onboarding: isPendingOnboarding ? 'true' : 'false', }, diff --git a/supabase/functions/_backend/triggers/on_organization_create.ts b/supabase/functions/_backend/triggers/on_organization_create.ts index 5fb63c8c3d..95e5c6a731 100644 --- a/supabase/functions/_backend/triggers/on_organization_create.ts +++ b/supabase/functions/_backend/triggers/on_organization_create.ts @@ -54,8 +54,13 @@ app.post('/', middlewareAPISecret, triggerValidator('orgs', 'INSERT'), async (c) event: 'Org Created', icon: '๐ŸŽ‰', sentToBento: true, - user_id: record.id, + // PostHog person must be the org creator, not the org id, so signup funnels + // can join this event with real user identities. Org id stays in groups/tags. + user_id: record.created_by ?? record.id, groups: { organization: record.id }, + tags: { + owner_org: record.id, + }, notify: false, }) diff --git a/supabase/functions/_backend/utils/postgres_schema.ts b/supabase/functions/_backend/utils/postgres_schema.ts index 6f831d6fde..2c4c184c3e 100644 --- a/supabase/functions/_backend/utils/postgres_schema.ts +++ b/supabase/functions/_backend/utils/postgres_schema.ts @@ -36,6 +36,7 @@ export const apps = pgTable('apps', { existing_app: boolean('existing_app').notNull().default(false), ios_store_url: text('ios_store_url'), android_store_url: text('android_store_url'), + first_update_delivered_at: timestamp('first_update_delivered_at', { withTimezone: true }), }) export const app_versions = pgTable('app_versions', { diff --git a/supabase/functions/_backend/utils/tracking.ts b/supabase/functions/_backend/utils/tracking.ts index b5675d2ba3..3b6a5259c7 100644 --- a/supabase/functions/_backend/utils/tracking.ts +++ b/supabase/functions/_backend/utils/tracking.ts @@ -30,6 +30,8 @@ export interface SendEventToTrackingPayload extends TrackOptions { bento?: BentoTrackingPayload groups?: PostHogGroups sentToBento?: boolean + /** Forwarded to PostHog: false prevents tags from being $set as person properties. */ + setPersonProperties?: boolean } export interface SendEventToTrackingOptions { @@ -68,6 +70,7 @@ async function executeTracking(c: Context, payload: SendEventToTrackingPayload, channel: payload.channel, description: payload.description, groups: payload.groups, + setPersonProperties: payload.setPersonProperties, ip: getTrackingIp(c, options.ip), })), ] diff --git a/supabase/migrations/20260611200850_add_first_update_delivered_at.sql b/supabase/migrations/20260611200850_add_first_update_delivered_at.sql new file mode 100644 index 0000000000..6c69ab52e6 --- /dev/null +++ b/supabase/migrations/20260611200850_add_first_update_delivered_at.sql @@ -0,0 +1,132 @@ +-- Activation tracking: timestamp of the first device-delivered update per app. +-- Claimed idempotently by the daily logsnag_insights cron, which emits the +-- 'First Device Update Delivered' analytics event for fresh activations. +ALTER TABLE "public"."apps" ADD COLUMN IF NOT EXISTS "first_update_delivered_at" timestamp with time zone; + +-- get_org_apps_with_last_upload returns the full apps row via `a.*`, so its +-- declared RETURNS TABLE must match the new physical column order. Changing a +-- function's return columns requires DROP + recreate. +DROP FUNCTION IF EXISTS "public"."get_org_apps_with_last_upload"( + "uuid", "text", "text", boolean, integer, integer +); + +CREATE OR REPLACE FUNCTION "public"."get_org_apps_with_last_upload"( + "p_org_id" "uuid", + "p_search" "text" DEFAULT NULL, + "p_sort_by" "text" DEFAULT 'last_upload_at', + "p_sort_desc" boolean DEFAULT true, + "p_limit" integer DEFAULT 10, + "p_offset" integer DEFAULT 0 +) +RETURNS TABLE( + "created_at" timestamp with time zone, + "app_id" character varying, + "icon_url" character varying, + "user_id" "uuid", + "name" character varying, + "last_version" character varying, + "updated_at" timestamp with time zone, + "id" "uuid", + "retention" bigint, + "owner_org" "uuid", + "default_upload_channel" character varying, + "transfer_history" "jsonb"[], + "channel_device_count" bigint, + "manifest_bundle_count" bigint, + "expose_metadata" boolean, + "allow_preview" boolean, + "allow_device_custom_id" boolean, + "need_onboarding" boolean, + "existing_app" boolean, + "ios_store_url" "text", + "android_store_url" "text", + "stats_updated_at" timestamp without time zone, + "stats_refresh_requested_at" timestamp without time zone, + "build_timeout_seconds" bigint, + "build_timeout_updated_at" timestamp with time zone, + "first_update_delivered_at" timestamp with time zone, + "last_upload_at" timestamp with time zone, + "total_count" bigint +) +LANGUAGE "plpgsql" +SECURITY INVOKER +SET "search_path" TO '' +AS $$ +DECLARE + v_limit integer := LEAST(GREATEST(COALESCE(p_limit, 10), 1), 100); + v_offset integer := GREATEST(COALESCE(p_offset, 0), 0); + v_search text := NULLIF(btrim(COALESCE(p_search, '')), ''); + -- Whitelist sort keys to avoid dynamic-SQL injection via p_sort_by. + v_sort text := CASE + WHEN p_sort_by IN ('name', 'last_version', 'updated_at', 'created_at', 'last_upload_at') + THEN p_sort_by + ELSE 'last_upload_at' + END; + v_desc boolean := COALESCE(p_sort_desc, true); +BEGIN + RETURN QUERY + WITH scoped AS ( + SELECT + a.*, + lv.created_at AS last_upload_at + FROM public.apps a + LEFT JOIN LATERAL ( + SELECT av.created_at + FROM public.app_versions av + WHERE av.app_id = a.app_id + AND av.name = a.last_version + AND av.deleted = false + ORDER BY av.created_at DESC + LIMIT 1 + ) lv ON a.last_version IS NOT NULL + WHERE a.owner_org = p_org_id + AND ( + v_search IS NULL + OR a.name ILIKE '%' || v_search || '%' + OR a.app_id ILIKE '%' || v_search || '%' + ) + ) + SELECT + s.*, + COUNT(*) OVER () AS total_count + FROM scoped s + ORDER BY + -- NULLS LAST in both directions so apps without uploads sort to the bottom. + CASE WHEN v_sort = 'last_upload_at' AND v_desc THEN s.last_upload_at END DESC NULLS LAST, + CASE WHEN v_sort = 'last_upload_at' AND NOT v_desc THEN s.last_upload_at END ASC NULLS LAST, + CASE WHEN v_sort = 'updated_at' AND v_desc THEN s.updated_at END DESC NULLS LAST, + CASE WHEN v_sort = 'updated_at' AND NOT v_desc THEN s.updated_at END ASC NULLS LAST, + CASE WHEN v_sort = 'created_at' AND v_desc THEN s.created_at END DESC NULLS LAST, + CASE WHEN v_sort = 'created_at' AND NOT v_desc THEN s.created_at END ASC NULLS LAST, + CASE WHEN v_sort = 'name' AND v_desc THEN s.name END DESC NULLS LAST, + CASE WHEN v_sort = 'name' AND NOT v_desc THEN s.name END ASC NULLS LAST, + CASE WHEN v_sort = 'last_version' AND v_desc THEN s.last_version END DESC NULLS LAST, + CASE WHEN v_sort = 'last_version' AND NOT v_desc THEN s.last_version END ASC NULLS LAST, + -- Stable tiebreaker so pagination is deterministic across pages. + s.app_id ASC + LIMIT v_limit + OFFSET v_offset; +END; +$$; + +ALTER FUNCTION "public"."get_org_apps_with_last_upload"( + "uuid", "text", "text", boolean, integer, integer +) OWNER TO "postgres"; + +COMMENT ON FUNCTION "public"."get_org_apps_with_last_upload"( + "uuid", "text", "text", boolean, integer, integer +) IS 'Paginated apps for one org with a derived last_upload_at (created_at of the bundle matching apps.last_version). Returns the full apps row plus last_upload_at and total_count. SECURITY INVOKER so RLS on apps/app_versions enforces visibility; p_org_id is an indexed narrowing filter on top of RLS. Search/sort/pagination/total_count are computed in SQL so page order matches the displayed last-upload sort.'; + +-- Least privilege: no PUBLIC, only the user-context roles the frontend uses plus service_role. +REVOKE ALL ON FUNCTION "public"."get_org_apps_with_last_upload"( + "uuid", "text", "text", boolean, integer, integer +) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_org_apps_with_last_upload"( + "uuid", "text", "text", boolean, integer, integer +) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_org_apps_with_last_upload"( + "uuid", "text", "text", boolean, integer, integer +) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_org_apps_with_last_upload"( + "uuid", "text", "text", boolean, integer, integer +) TO "service_role"; diff --git a/tests/organization-store-delete.unit.test.ts b/tests/organization-store-delete.unit.test.ts index 550671e06a..4e288dd3f8 100644 --- a/tests/organization-store-delete.unit.test.ts +++ b/tests/organization-store-delete.unit.test.ts @@ -35,6 +35,10 @@ const mainStore = { vi.mock('~/services/supabase', () => ({ isPlatformAdmin: mockIsPlatformAdmin, stripeEnabled: ref(true), + // The store's currentOrganization watch tags PostHog with the org group; + // isLocal=true makes setOrganization a no-op in tests. + getLocalConfig: () => ({ supaHost: 'http://localhost:54321' }), + isLocal: () => true, useSupabase: () => ({ auth: { onAuthStateChange: vi.fn(() => ({ diff --git a/tests/tracking.unit.test.ts b/tests/tracking.unit.test.ts index 1af49ea6ad..92988bfa35 100644 --- a/tests/tracking.unit.test.ts +++ b/tests/tracking.unit.test.ts @@ -136,4 +136,27 @@ describe('sendEventToTracking', () => { provider: 'logsnag', })) }) + + it('forwards setPersonProperties to PostHog so tags do not pollute person properties', async () => { + const { sendEventToTracking } = await import('../supabase/functions/_backend/utils/tracking.ts') + + await sendEventToTracking(createContext(), { + channel: 'app-activation', + event: 'First Device Update Delivered', + user_id: 'user-id', + groups: { organization: 'org-id' }, + tags: { app_id: 'app-id' }, + setPersonProperties: false, + notify: false, + }, { + background: false, + }) + + expect(posthogMock).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + event: 'First Device Update Delivered', + user_id: 'user-id', + groups: { organization: 'org-id' }, + setPersonProperties: false, + })) + }) })