diff --git a/package.json b/package.json index 543533b..f0efc3b 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "build": "tsc", "dev": "tsx watch src/index.ts", "backfill:bot-flags": "tsx src/scripts/backfill-bot-flags.ts", + "backfill:attribution": "tsx src/scripts/backfill-attribution.ts", "migrate": "tsx src/scripts/migrate.ts", "test": "vitest run", "test:watch": "vitest", diff --git a/src/lib/database.ts b/src/lib/database.ts index f1531c0..3366f71 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -140,6 +140,8 @@ export async function initializeDatabase(options: DatabaseOptions = {}) { click_id UUID REFERENCES click_events(id) ON DELETE SET NULL, fingerprint_hash VARCHAR(64) NOT NULL, confidence_score DECIMAL(5, 2), + attribution_method VARCHAR(20), + matched_factors TEXT[], installed_at TIMESTAMP DEFAULT NOW(), first_open_at TIMESTAMP, deep_link_retrieved BOOLEAN DEFAULT false, @@ -407,6 +409,21 @@ export async function initializeDatabase(options: DatabaseOptions = {}) { END $$; `); + // Attribution metadata on install_events (SIT-296): how the install was + // attributed ('fingerprint' | 'none') and which fingerprint signals matched. + // Makes attribution quality measurable. Backward compatible (NULL until set). + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='attribution_method') THEN + ALTER TABLE install_events ADD COLUMN attribution_method VARCHAR(20); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='matched_factors') THEN + ALTER TABLE install_events ADD COLUMN matched_factors TEXT[]; + END IF; + END $$; + `); + // Last-click attribution columns on in_app_events (SIT-237). // Events (screen views + custom events) are attributed to the deep link that // drove them, not just the original install link. The SDK stamps each event diff --git a/src/lib/fingerprint.test.ts b/src/lib/fingerprint.test.ts index d2b3431..89c8133 100644 --- a/src/lib/fingerprint.test.ts +++ b/src/lib/fingerprint.test.ts @@ -343,10 +343,12 @@ describe('recordInstallEvent', () => { expect(sql).toMatch(/INSERT INTO install_events/); expect(sql).toMatch(/sdk_name/); expect(sql).toMatch(/sdk_version/); - // sdk_name / sdk_version are the LAST two positional values ($16, $17) - expect(params).toHaveLength(17); - expect(params[params.length - 2]).toBe('react-native'); - expect(params[params.length - 1]).toBe('1.4.0'); + // sdk_name/$16, sdk_version/$17, then attribution_method/$18, matched_factors/$19 + expect(params).toHaveLength(19); + expect(params[15]).toBe('react-native'); // sdk_name + expect(params[16]).toBe('1.4.0'); // sdk_version + expect(params[17]).toBe('none'); // attribution_method (no fingerprint match) + expect(params[18]).toBeNull(); // matched_factors (no match) }); it('stores null sdk on the install row when the metadata is absent or empty', async () => { @@ -356,7 +358,7 @@ describe('recordInstallEvent', () => { await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, { name: '', version: undefined }); const params = mockDbQuery.mock.calls[1][1]; - expect(params[params.length - 2]).toBeNull(); // '' -> null - expect(params[params.length - 1]).toBeNull(); // undefined -> null + expect(params[15]).toBeNull(); // sdk_name '' -> null + expect(params[16]).toBeNull(); // sdk_version undefined -> null }); }); diff --git a/src/lib/fingerprint.ts b/src/lib/fingerprint.ts index 063e5f9..4704513 100644 --- a/src/lib/fingerprint.ts +++ b/src/lib/fingerprint.ts @@ -400,6 +400,12 @@ export async function recordInstallEvent( // Attempt to match install to a click const match = await matchInstallToClick(fingerprintData, attributionWindowHours); + // Attribution metadata for measurement (SIT-296): install attribution is + // fingerprint-based, so the method is 'fingerprint' on a match and 'none' + // (organic) otherwise; matched_factors records which signals matched. + const attributionMethod = match ? 'fingerprint' : 'none'; + const matchedFactors = match?.matchedFactors ?? null; + // Insert install event const installResult = await db.query( `INSERT INTO install_events ( @@ -421,8 +427,10 @@ export async function recordInstallEvent( device_id, deep_link_data, sdk_name, - sdk_version - ) VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + sdk_version, + attribution_method, + matched_factors + ) VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id, deep_link_data`, [ match?.linkId || null, @@ -442,6 +450,8 @@ export async function recordInstallEvent( match ? JSON.stringify({}) : JSON.stringify({}), // Will be populated from link data sdk?.name || null, sdk?.version || null, + attributionMethod, + matchedFactors, ] ); diff --git a/src/scripts/backfill-attribution.ts b/src/scripts/backfill-attribution.ts new file mode 100644 index 0000000..deb3b08 --- /dev/null +++ b/src/scripts/backfill-attribution.ts @@ -0,0 +1,50 @@ +/** + * One-time backfill of install_events.attribution_method for rows recorded + * before attribution metadata existed (SIT-296). Historical rows only tell us + * whether they were attributed (link_id present), so this sets: + * - 'fingerprint' when link_id IS NOT NULL (current attribution is fingerprint-based) + * - 'none' otherwise (organic) + * `matched_factors` is left NULL for history — the matched signals weren't + * stored and can't be reconstructed; new installs record it going forward. + * + * Keyset-paginated over the primary key; idempotent (only touches NULL rows). + * Run: `npm run backfill:attribution` (needs DATABASE_URL). + */ +import { db, initializeDatabase } from '../lib/database.js'; + +const BATCH = 5000; + +async function backfill() { + await initializeDatabase(); + let cursor = '00000000-0000-0000-0000-000000000000'; + let scanned = 0; + let updated = 0; + + for (;;) { + const { rows } = await db.query( + `SELECT id FROM install_events WHERE id > $1 ORDER BY id LIMIT $2`, + [cursor, BATCH] + ); + if (rows.length === 0) break; + + const ids = rows.map((r: { id: string }) => r.id); + const res = await db.query( + `UPDATE install_events + SET attribution_method = CASE WHEN link_id IS NOT NULL THEN 'fingerprint' ELSE 'none' END + WHERE id = ANY($1::uuid[]) AND attribution_method IS NULL`, + [ids] + ); + updated += res.rowCount ?? 0; + scanned += rows.length; + cursor = rows[rows.length - 1].id; + console.log(`[backfill-attribution] scanned ${scanned}, set ${updated}`); + } + + console.log(`[backfill-attribution] done: scanned ${scanned}, set attribution_method on ${updated}`); + process.exit(0); +} + +backfill().catch((error) => { + console.error('[backfill-attribution] failed:', error); + process.exit(1); +});