diff --git a/docs/posthog-web-analytics.md b/docs/posthog-web-analytics.md index c62a0e75..bfee4733 100644 --- a/docs/posthog-web-analytics.md +++ b/docs/posthog-web-analytics.md @@ -115,6 +115,66 @@ Despliega primero el backend de campus-profes y después el sitio. Para verifica en producción, abre una sesión de navegador limpia, llega desde un enlace externo, entra y comprueba las propiedades del evento de acceso en PostHog. +### Canal de la primera visita + +Además de la cookie de OAuth, el navegador guarda en `localStorage` +(`campus_entry_first_v1`, 30 días) el primer canal observado: la primera +visita gana. Solo una visita `direct` (sin referrer) cede ante un canal real +posterior, para que un marcador no oculte una campaña. Respeta la baja de +analítica: se borra al leerla o en la siguiente visita, y también en cada +`resetAnalytics` (cierre de sesión o cambio de cuenta en un navegador compartido). +Guarda solo valores del contrato cerrado: `entry_channel` (`mcp`, `campaign`, +`referral`, `direct`), `entry_source` (dominio del referrer, cliente de IA o +fuente de campaña admitida), `entry_campaign` (etiqueta o ID admitido) y +`entry_landing` (familia de ruta, nunca slug ni consulta). No se admiten UTMs +arbitrarias; solo las campañas ya listadas arriba. El contrato valida la +fuente según el canal: cliente de IA conocido (`mcp`), `meta` o `email` +(`campaign`, con ID numérico) o un hostname (solo `referral`). + +Atención al comparar: la cookie de OAuth que lee el backend es la entrada de la +sesión (24 h), mientras que los eventos del navegador llevan la primera entrada +de 30 días. Los dos modelos pueden diferir para una misma persona. + +La cookie `campus_entry` de OAuth incluye ahora `entry_landing`. Estas cuatro +propiedades se adjuntan en el navegador a `campus_account_access_completed`, +`campus_plus_checkout_viewed`, `campus_plus_checkout_started`, +`campus_plus_submit_started`, `campus_turnitin_submit_started` y +`campus_turnitin_submitted`. `campus_auth_completed` lo emite el backend de +campus-profes a partir de esa cookie: para que lleve `entry_landing` el backend +debe copiarla (pendiente en ese repositorio). + +#### Logins sin `$pageview` previo + +Hipótesis, sin comprobar contra los datos de producción: `campus_auth_completed` +se emite al terminar OAuth, y no todos los accesos pasan por el sitio. +1. **CLI y MCP**: `campus login` y la conexión desde ChatGPT/Claude/Gemini + empiezan en el relay o en el proveedor, sin cargar ninguna página, así que + no hay vista ni cookie `campus_entry`. +2. **Baja de analítica o bloqueadores**: el sitio no envía vistas ni cookie. +3. **Bots**: el cliente descarta sus eventos. +4. **Primera visita en otra sesión o dispositivo** (el `distinct_id` anónimo no + se une al perfil). +Para separar causas, cruza cada login con el `$pageview` del mismo +`$session_id` y con la presencia de `entry_channel` en el evento. + +#### Consulta de ejemplo: logins por canal + +```sql +SELECT + coalesce(properties.entry_channel, 'sin_canal') AS canal, + coalesce(properties.entry_source, '') AS fuente, + count() AS logins, + uniq(person_id) AS personas +FROM events +WHERE event = 'campus_auth_completed' + AND timestamp >= now() - INTERVAL 30 DAY +GROUP BY canal, fuente +ORDER BY logins DESC +``` + +Para la conversión a Plus por canal usa `campus_plus_checkout_started` con el +mismo desglose. + ## Configuración y despliegue Publicar los cambios mediante PR y el despliegue automático de `main`. Una diff --git a/site/package.json b/site/package.json index b47a8c84..55d1d3a4 100644 --- a/site/package.json +++ b/site/package.json @@ -23,7 +23,7 @@ "test:materials-handoff": "node scripts/materials-handoff-e2e.mjs", "pdf-assets": "node scripts/copy-pdf-reader-assets.mjs", "test:plus:e2e": "node scripts/plus-e2e.mjs", - "test:analytics": "node scripts/verify-analytics.mjs && node scripts/verify-account-onboarding.mjs && node scripts/plus-survey.test.mjs && node scripts/campaign-attribution.test.mjs", + "test:analytics": "node scripts/verify-analytics.mjs && node scripts/verify-account-onboarding.mjs && node scripts/plus-survey.test.mjs && node scripts/campaign-attribution.test.mjs && node scripts/entry-attribution.test.mjs", "test:analytics:transport": "node scripts/analytics-transport.test.mjs && node scripts/analytics-client.test.mjs && node scripts/plus-survey-qa-transport.test.mjs && node scripts/marketing-consent.test.mjs && node scripts/analytics-unload.test.mjs", "test:review-replies": "node scripts/review-replies.e2e.mjs", "test:first-question": "node scripts/first-question-e2e.mjs", diff --git a/site/scripts/analytics-transport.test.mjs b/site/scripts/analytics-transport.test.mjs index 056c2177..bbdd64f9 100644 --- a/site/scripts/analytics-transport.test.mjs +++ b/site/scripts/analytics-transport.test.mjs @@ -70,7 +70,8 @@ try{ const authUrl = 'https://campus.analytics.test/api/app/auth/start'; const entry = (await context.cookies(authUrl)).find(cookie => cookie.name === 'campus_entry'); assert.ok(entry); - assert.deepEqual(JSON.parse(decodeURIComponent(entry.value)), {entry_channel:'mcp',entry_source:'chatgpt'}); + assert.deepEqual(JSON.parse(decodeURIComponent(entry.value)), {entry_channel:'mcp',entry_source:'chatgpt',entry_landing:'turnitin_app'}); + assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('campus_entry_first_v1'))).entry,{entry_channel:'mcp',entry_source:'chatgpt',entry_landing:'turnitin_app'}); assert.ok(!(await context.cookies(page.url())).some(cookie => cookie.name === 'campus_entry')); await page.evaluate(() => window.finishLogin()); assert.ok(!(await context.cookies(authUrl)).some(cookie => cookie.name === 'campus_entry')); @@ -86,6 +87,9 @@ try{ assert.equal(event.properties.current_plan,'free');assert.equal(event.properties.environment,'production'); } assert.equal(events.find(e=>e.event==='campus_plus_checkout_viewed').properties.amount_cents,500); + const checkout=events.find(e=>e.event==='campus_plus_checkout_viewed').properties; + assert.equal(checkout.entry_channel,'mcp');assert.equal(checkout.entry_source,'chatgpt');assert.equal(checkout.entry_landing,'turnitin_app'); + assert.ok(!('entry_channel' in events.find(e=>e.event==='campus_cta_clicked').properties),'Only conversion events carry the entry channel'); const surveyEvents=events.filter(e=>e.event==='survey sent'); assert.equal(surveyEvents.length,2); assert.equal(surveyEvents[0].properties.$survey_submission_id,surveyEvents[1].properties.$survey_submission_id); diff --git a/site/scripts/entry-attribution.test.mjs b/site/scripts/entry-attribution.test.mjs new file mode 100644 index 00000000..b55b2af2 --- /dev/null +++ b/site/scripts/entry-attribution.test.mjs @@ -0,0 +1,70 @@ +// First-visit entry persistence: first wins, "direct" yields to a later channel, +// expiry, corrupt storage, opt-out and reset. Runs the real module with stubbed browser globals. +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; + +const { outputFiles } = await build({ entryPoints: ['src/scripts/entry-attribution.ts'], bundle: true, format: 'esm', write: false }); +const mod = await import(`data:text/javascript;base64,${Buffer.from(outputFiles[0].text).toString('base64')}`); +const store = () => { const m = new Map(); return { getItem: k => m.get(k) ?? null, setItem: (k, v) => m.set(k, String(v)), removeItem: k => m.delete(k), clear: () => m.clear() }; }; +const KEY = 'phc_test'; +function visit({ url = 'https://campuscli.com/', referrer = '', fresh = true } = {}) { + const u = new URL(url); + globalThis.location = { href: u.href, search: u.search, pathname: u.pathname, hostname: u.hostname }; + globalThis.document = { referrer, cookie: '' }; + if (fresh) globalThis.sessionStorage = store(); + mod.rememberEntry(KEY); +} +globalThis.localStorage = store(); + +visit({ referrer: 'https://www.google.com/search?q=private' }); +assert.deepEqual(mod.firstEntry(KEY), { entry_channel: 'referral', entry_source: 'www.google.com', entry_landing: 'home' }); +visit({ url: 'https://campuscli.com/turnitin/?utm_source=facebook&utm_campaign=123456789', referrer: 'https://l.facebook.com/' }); +assert.equal(mod.firstEntry(KEY).entry_channel, 'referral', 'The first observed channel is never overwritten'); + +localStorage.clear(); +visit({ referrer: '' }); +assert.equal(mod.firstEntry(KEY).entry_channel, 'direct'); +visit({ url: 'https://campuscli.com/?utm_source=facebook&utm_campaign=123456789' }); +assert.deepEqual(mod.firstEntry(KEY), { entry_channel: 'campaign', entry_source: 'meta', entry_campaign: '123456789', entry_landing: 'home' }, + 'A bare direct visit must not hide a later campaign'); +visit({ referrer: '' }); +assert.equal(mod.firstEntry(KEY).entry_channel, 'campaign', 'Direct never replaces a real channel'); + +// Same tab, same sessionStorage: a cached "direct" entry must not mask a campaign reached later. +localStorage.clear(); +visit({ referrer: '' }); +visit({ url: 'https://campuscli.com/?utm_source=facebook&utm_campaign=123456789', fresh: false }); +assert.equal(mod.firstEntry(KEY).entry_channel, 'campaign', 'Cached direct yields to a campaign in the same tab'); +assert.equal(JSON.parse(decodeURIComponent(JSON.parse(sessionStorage.getItem('campus_entry')).value)).entry_channel, 'campaign', 'The session entry is replaced too'); +visit({ referrer: '', fresh: false }); +assert.equal(mod.firstEntry(KEY).entry_channel, 'campaign', 'A later direct visit in the same tab keeps the campaign'); + +// A tab opened before the feature (session entry present, local entry missing) still records it. +localStorage.clear(); +visit({ referrer: 'https://www.reddit.com/r/upc' }); +const session = sessionStorage.getItem('campus_entry'); +localStorage.clear(); +globalThis.sessionStorage = store(); sessionStorage.setItem('campus_entry', session); +visit({ fresh: false }); +assert.equal(mod.firstEntry(KEY).entry_source, 'www.reddit.com'); + +// Expiry and corrupt storage. +localStorage.setItem('campus_entry_first_v1', JSON.stringify({ entry: { entry_channel: 'direct' }, expires: Date.now() - 1 })); +assert.deepEqual(mod.firstEntry(KEY), {}); +localStorage.setItem('campus_entry_first_v1', 'not json'); +assert.deepEqual(mod.firstEntry(KEY), {}); +localStorage.setItem('campus_entry_first_v1', JSON.stringify({ entry: { entry_channel: 'direct' }, expires: Date.now() + 365 * 86_400_000 })); +assert.deepEqual(mod.firstEntry(KEY), {}, 'A forged far-future expiry is rejected'); + +// Opt-out clears on read and on the next visit; clearFirstEntry empties it. +localStorage.clear(); +visit({ referrer: 'https://www.google.com/' }); +assert.ok(localStorage.getItem('campus_entry_first_v1')); +localStorage.setItem(`__ph_opt_in_out_${KEY}`, '0'); +assert.deepEqual(mod.firstEntry(KEY), {}); +assert.equal(localStorage.getItem('campus_entry_first_v1'), null, 'Opt-out removes the stored entry even without a new visit'); +localStorage.clear(); +visit({ referrer: 'https://www.google.com/' }); +mod.clearFirstEntry(); +assert.deepEqual(mod.firstEntry(KEY), {}); +console.log('Entry attribution: first wins, direct yields, expiry, corrupt storage, opt-out and reset passed.'); diff --git a/site/scripts/verify-analytics.mjs b/site/scripts/verify-analytics.mjs index ff8fb8fc..4cf6a6a8 100644 --- a/site/scripts/verify-analytics.mjs +++ b/site/scripts/verify-analytics.mjs @@ -12,6 +12,17 @@ assert.deepEqual(deployConfig.rewrites.slice(0, 3), [ assert.doesNotMatch(csp.split(';').find(rule => rule.trim().startsWith('script-src')), /posthog|unsafe-eval|unsafe-inline/); const { outputFiles } = await build({ entryPoints: ['src/scripts/analytics-contract.ts'], bundle: true, format: 'esm', write: false }); const { safeAnalyticsProperties, analyticsPage } = await import(`data:text/javascript;base64,${Buffer.from(outputFiles[0].text).toString('base64')}`); +assert.deepEqual(safeAnalyticsProperties({ entry_channel:'referral', entry_source:'www.google.com', entry_landing:'home' }), + { entry_channel:'referral', entry_source:'www.google.com', entry_landing:'home' }); +assert.deepEqual(safeAnalyticsProperties({ entry_channel:'campaign', entry_source:'meta', entry_campaign:'123456789' }), + { entry_channel:'campaign', entry_source:'meta', entry_campaign:'123456789' }); +assert.deepEqual(safeAnalyticsProperties({ entry_channel:'mcp', entry_source:'chatgpt' }), { entry_channel:'mcp', entry_source:'chatgpt' }); +// Closed contract: free text never passes, whatever the channel. +assert.deepEqual(safeAnalyticsProperties({ entry_channel:'organic', entry_source:'https://x.test/?q=private', entry_landing:'/profes/upc/ana/', entry_campaign:'Private Name' }), {}); +assert.deepEqual(safeAnalyticsProperties({ entry_channel:'campaign', entry_source:'juan_perez_gmail_com', entry_campaign:'juan_perez_gmail_com' }), { entry_channel:'campaign' }); +assert.deepEqual(safeAnalyticsProperties({ entry_channel:'mcp', entry_source:'alice.smith', entry_campaign:'123456789' }), { entry_channel:'mcp' }); +assert.deepEqual(safeAnalyticsProperties({ entry_channel:'direct', entry_source:'www.google.com' }), { entry_channel:'direct' }); +assert.deepEqual(safeAnalyticsProperties({ entry_source:'meta' }), {}); const webBundle = await build({ entryPoints: ['src/scripts/web-analytics.ts'], bundle: true, format: 'esm', write: false }); const { metaCampaign, engageCampaign, safeWebProperties, webPageProperties, ScrollDepth } = await import(`data:text/javascript;base64,${Buffer.from(webBundle.outputFiles[0].text).toString('base64')}`); assert.deepEqual(safeWebProperties({ diff --git a/site/src/scripts/analytics-contract.ts b/site/src/scripts/analytics-contract.ts index 81b42f4f..5bd67e91 100644 --- a/site/src/scripts/analytics-contract.ts +++ b/site/src/scripts/analytics-contract.ts @@ -45,14 +45,28 @@ const categories: Record = { aiClient: ['chatgpt', 'gemini', 'claude', 'cursor', 'vscode'], aiClientSource: ['handoff_url'], upload_kind: ['document', 'receipt'], copy_kind: ['name', 'url'], + entry_channel: ['mcp', 'campaign', 'referral', 'direct'], viewport: ['mobile', 'tablet', 'desktop'], environment: ['development', 'production'], }; const numbers = new Set(['remaining', 'limit', 'duration_ms', 'position', 'amount_cents', 'http_status']); const booleans = new Set(['proactive_enabled','email_enabled','push_enabled','callback_requested','authenticated', 'supported', 'valid', 'new_conversation', 'university_confirmed', 'is_test','is_internal']); +// The source is closed per channel: a known AI client, a known campaign source +// or, only for referrals, a bare hostname. Campaign IDs are numeric. +const entrySources: Record = { mcp: categories.aiClient, campaign: ['meta', 'email'] }; +function entryValue(key: string, value: unknown, input: Record): boolean { + if (typeof value !== 'string') return false; + const channel = input.entry_channel as string; + if (key === 'entry_campaign') return channel === 'campaign' && /^[0-9]{6,32}$/.test(value); + if (key === 'entry_source') return channel === 'referral' + ? /^[a-z0-9](?:[a-z0-9.-]{0,98}[a-z0-9])?$/.test(value) : Boolean(entrySources[channel]?.includes(value)); + return false; +} export function safeAnalyticsProperties(input: Record): Record { return Object.fromEntries(Object.entries(input).filter(([key, value]) => (['trace_id','conversation_id'].includes(key) && typeof value==='string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value)) - || (Object.hasOwn(categories, key) && categories[key].includes(value as string)) + || ((Object.hasOwn(categories, key) && categories[key].includes(value as string)) + || (key === 'entry_landing' && categories.page.includes(value as string))) + || (['entry_source', 'entry_campaign'].includes(key) && entryValue(key, value, input)) || (numbers.has(key) && typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 86_400_000) || (booleans.has(key) && typeof value === 'boolean'))); } diff --git a/site/src/scripts/analytics.ts b/site/src/scripts/analytics.ts index 144a98b0..f102446b 100644 --- a/site/src/scripts/analytics.ts +++ b/site/src/scripts/analytics.ts @@ -1,6 +1,6 @@ import { beginAccountHandoff, completeAccountHandoff } from './account-onboarding.ts'; import { mcpHandoff, clearMcpHandoff } from './mcp-handoff.ts'; -import { clearEntry, rememberEntry } from './entry-attribution.ts'; +import { clearEntry, clearFirstEntry, firstEntry, rememberEntry } from './entry-attribution.ts'; import { clearCampaign, rememberCampaign, syncCampaign } from './campaign-attribution.ts'; import { syncMarketingPreference, clearMarketingPreference } from './marketing-consent.ts'; import { analyticsPage, events, safeAnalyticsProperties } from './analytics-contract.ts'; @@ -16,6 +16,9 @@ const eventProperties = (event: string, properties: Record) => ...safeSurveyProperties(event, properties), }); +// Conversion events carry the first observed entry channel (see docs/posthog-web-analytics.md). +const entryEvents = new Set(['campus_account_access_completed', 'campus_plus_checkout_viewed', 'campus_plus_checkout_started', + 'campus_plus_submit_started', 'campus_turnitin_submit_started', 'campus_turnitin_submitted']); type Event = { event: string; properties: Record }; const browser = typeof window !== 'undefined' && typeof location !== 'undefined' && typeof location.pathname === 'string' && typeof location.hostname === 'string'; @@ -142,7 +145,8 @@ export function track(event: string, properties: Record = {}): ...(isTest!==undefined?{is_test:isTest}:{}), selected_plan: selectedPlan(), viewport: innerWidth < 761 ? 'mobile' : innerWidth < 1200 ? 'tablet' : 'desktop', - environment: local ? 'development' : 'production', ...properties, + environment: local ? 'development' : 'production', + ...(entryEvents.has(event) && !local && key ? firstEntry(key) : {}), ...properties, analytics_audience:audience,is_internal:audience==='internal', ...(isTest!==undefined||properties.is_test===true?{is_test:isTest===true||properties.is_test===true}:{}), }) }; @@ -199,6 +203,8 @@ export function analyticsSession(session: CampusSession): void { track(firstReady ? 'campus_session_ready' : 'campus_usage_updated', { authenticated: session.authenticated, remaining: session.usage?.remaining, limit: session.usage?.limit }); } export function resetAnalytics(forgetCampaign = false): void { + // The next person on a shared browser must not inherit this account's entry channel. + if (browser) clearFirstEntry(); if (browser && forgetCampaign) clearCampaign(); if (browser && forgetCampaign) clearMarketingPreference(); if (browser) { try { clearMcpHandoff(sessionStorage); } catch { /* Optional. */ } } diff --git a/site/src/scripts/entry-attribution.ts b/site/src/scripts/entry-attribution.ts index 61a15510..ab69a7e1 100644 --- a/site/src/scripts/entry-attribution.ts +++ b/site/src/scripts/entry-attribution.ts @@ -1,8 +1,11 @@ import { mcpHandoff } from './mcp-handoff.ts'; import { metaCampaign } from './web-analytics.ts'; +import { analyticsPage } from './analytics-contract.ts'; const key = 'campus_entry'; const ttl = 24 * 60 * 60_000; +const firstKey = 'campus_entry_first_v1'; +const firstTtl = 30 * 86_400_000; const cookieOptions = 'Path=/api/app/auth/; SameSite=Lax; Secure'; export function rememberEntry(projectKey: string, startingAuth = false): void { @@ -11,14 +14,21 @@ export function rememberEntry(projectKey: string, startingAuth = false): void { const consent = JSON.parse(localStorage.getItem(`ph_campus_analytics_v2_${projectKey}`) ?? 'null'); if (consent?.opted_out === true || localStorage.getItem(`__ph_opt_in_out_${projectKey}`) === '0') { clearEntry(); + clearFirstEntry(); return; } const now = Date.now(); const previous = JSON.parse(sessionStorage.getItem(key) ?? 'null'); - if (previous?.expires > now && previous.expires <= now + ttl) { + const cached = previous?.expires > now && previous.expires <= now + ttl; + let cachedEntry: Entry | undefined; + if (cached) try { cachedEntry = JSON.parse(decodeURIComponent(previous.value)); } catch { /* Optional. */ } + const keepCached = () => { document.cookie = `${key}=${previous.value}; Max-Age=${Math.ceil((previous.expires - now) / 1000)}; ${cookieOptions}`; - return; - } + // A tab opened before this feature, or cleared storage, must still record the first entry. + if (cachedEntry) saveFirstEntry(cachedEntry, now); + }; + // A cached bare "direct" visit must not hide a real channel reached later in the same tab. + if (cached && cachedEntry?.entry_channel !== 'direct') { keepCached(); return; } const handoff = mcpHandoff(location.href, sessionStorage, now); const campaign = metaCampaign(location.search); const referrer = document.referrer ? new URL(document.referrer) : null; @@ -29,11 +39,15 @@ export function rememberEntry(projectKey: string, startingAuth = false): void { : campaign.utm_campaign ? { entry_channel: 'campaign', entry_source: campaign.utm_source, entry_campaign: campaign.utm_campaign } : external ? { entry_channel: 'referral', entry_source: domain } : !referrer ? { entry_channel: 'direct' } : null; + if (cached && (!entry || entry.entry_channel === 'direct')) { keepCached(); return; } if (!entry) { if (startingAuth) clearEntry(); return; } - const value = encodeURIComponent(JSON.stringify(entry)); + // Landing is a closed route family, never a path, slug or query string. + const withLanding = { ...entry, entry_landing: analyticsPage(location.pathname) }; + saveFirstEntry(withLanding, now); + const value = encodeURIComponent(JSON.stringify(withLanding)); sessionStorage.setItem(key, JSON.stringify({ value, expires: now + ttl })); document.cookie = `${key}=${value}; Max-Age=${ttl / 1000}; ${cookieOptions}`; } catch { @@ -47,3 +61,35 @@ export function clearEntry(): void { sessionStorage.removeItem(key); } catch { /* Optional. */ } } + +type Entry = Record; +function saveFirstEntry(entry: Entry, now: number): void { + try { + // The first observed entry wins. Only a bare "direct" visit (bookmark, app + // without referrer) yields to a later channel, so it cannot hide a campaign. + const saved = readFirst(now); + if (saved && !(saved.entry_channel === 'direct' && entry.entry_channel !== 'direct')) return; + localStorage.setItem(firstKey, JSON.stringify({ entry, expires: now + firstTtl })); + } catch { /* Optional. */ } +} +function readFirst(now: number): Entry | undefined { + try { + const saved = JSON.parse(localStorage.getItem(firstKey) ?? 'null'); + if (saved?.expires > now && saved.expires <= now + firstTtl && saved.entry && typeof saved.entry === 'object') return saved.entry; + } catch { /* Optional. */ } +} +// First observed channel for conversion events. Values pass the analytics +// contract before leaving the browser; none is a URL, query or personal data. +export function firstEntry(projectKey: string): Entry { + if (optedOut(projectKey)) { clearFirstEntry(); return {}; } + return readFirst(Date.now()) ?? {}; +} +function optedOut(projectKey: string): boolean { + try { + return JSON.parse(localStorage.getItem(`ph_campus_analytics_v2_${projectKey}`) ?? 'null')?.opted_out === true + || localStorage.getItem(`__ph_opt_in_out_${projectKey}`) === '0'; + } catch { return false; } +} +export function clearFirstEntry(): void { + try { localStorage.removeItem(firstKey); } catch { /* Optional. */ } +}