Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/posthog-web-analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion site/scripts/analytics-transport.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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);
Expand Down
70 changes: 70 additions & 0 deletions site/scripts/entry-attribution.test.mjs
Original file line number Diff line number Diff line change
@@ -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.');
11 changes: 11 additions & 0 deletions site/scripts/verify-analytics.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
16 changes: 15 additions & 1 deletion site/src/scripts/analytics-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,28 @@ const categories: Record<string, readonly string[]> = {
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<string, readonly string[]> = { mcp: categories.aiClient, campaign: ['meta', 'email'] };
function entryValue(key: string, value: unknown, input: Record<string, unknown>): 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<string, unknown>): Record<string, unknown> {
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')));
}
Expand Down
10 changes: 8 additions & 2 deletions site/src/scripts/analytics.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +16,9 @@ const eventProperties = (event: string, properties: Record<string, unknown>) =>
...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<string, unknown> };
const browser = typeof window !== 'undefined' && typeof location !== 'undefined'
&& typeof location.pathname === 'string' && typeof location.hostname === 'string';
Expand Down Expand Up @@ -142,7 +145,8 @@ export function track(event: string, properties: Record<string, unknown> = {}):
...(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}:{}),
}) };
Expand Down Expand Up @@ -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. */ } }
Expand Down
Loading