Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions src/lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions src/lib/fingerprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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
});
});
14 changes: 12 additions & 2 deletions src/lib/fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand All @@ -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,
]
);

Expand Down
50 changes: 50 additions & 0 deletions src/scripts/backfill-attribution.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading