Skip to content

Commit b82dce4

Browse files
authored
feat: record install attribution method and matched signals (#32)
Make install attribution measurable. install_events gains attribution_method ('fingerprint' on a match, 'none' when organic) and matched_factors (the fingerprint signals that matched — already computed by the matcher, now persisted). Set in recordInstallEvent; idempotent ALTER for existing databases. Adds scripts/backfill-attribution.ts (npm run backfill:attribution): one-time, keyset-paginated backfill of attribution_method for historical rows from whether they were attributed (link_id present); matched_factors stays NULL for history. Enables measuring attribution quality by method (fingerprint vs organic) and auditing which signals drove each match. Verified: 139 unit tests pass; an integration check confirms the columns, TEXT[] persistence, and the backfill.
1 parent 37fc820 commit b82dce4

5 files changed

Lines changed: 88 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"build": "tsc",
3939
"dev": "tsx watch src/index.ts",
4040
"backfill:bot-flags": "tsx src/scripts/backfill-bot-flags.ts",
41+
"backfill:attribution": "tsx src/scripts/backfill-attribution.ts",
4142
"migrate": "tsx src/scripts/migrate.ts",
4243
"test": "vitest run",
4344
"test:watch": "vitest",

src/lib/database.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
140140
click_id UUID REFERENCES click_events(id) ON DELETE SET NULL,
141141
fingerprint_hash VARCHAR(64) NOT NULL,
142142
confidence_score DECIMAL(5, 2),
143+
attribution_method VARCHAR(20),
144+
matched_factors TEXT[],
143145
installed_at TIMESTAMP DEFAULT NOW(),
144146
first_open_at TIMESTAMP,
145147
deep_link_retrieved BOOLEAN DEFAULT false,
@@ -407,6 +409,21 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
407409
END $$;
408410
`);
409411

412+
// Attribution metadata on install_events (SIT-296): how the install was
413+
// attributed ('fingerprint' | 'none') and which fingerprint signals matched.
414+
// Makes attribution quality measurable. Backward compatible (NULL until set).
415+
await client.query(`
416+
DO $$
417+
BEGIN
418+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='attribution_method') THEN
419+
ALTER TABLE install_events ADD COLUMN attribution_method VARCHAR(20);
420+
END IF;
421+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='matched_factors') THEN
422+
ALTER TABLE install_events ADD COLUMN matched_factors TEXT[];
423+
END IF;
424+
END $$;
425+
`);
426+
410427
// Last-click attribution columns on in_app_events (SIT-237).
411428
// Events (screen views + custom events) are attributed to the deep link that
412429
// drove them, not just the original install link. The SDK stamps each event

src/lib/fingerprint.test.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -343,10 +343,12 @@ describe('recordInstallEvent', () => {
343343
expect(sql).toMatch(/INSERT INTO install_events/);
344344
expect(sql).toMatch(/sdk_name/);
345345
expect(sql).toMatch(/sdk_version/);
346-
// sdk_name / sdk_version are the LAST two positional values ($16, $17)
347-
expect(params).toHaveLength(17);
348-
expect(params[params.length - 2]).toBe('react-native');
349-
expect(params[params.length - 1]).toBe('1.4.0');
346+
// sdk_name/$16, sdk_version/$17, then attribution_method/$18, matched_factors/$19
347+
expect(params).toHaveLength(19);
348+
expect(params[15]).toBe('react-native'); // sdk_name
349+
expect(params[16]).toBe('1.4.0'); // sdk_version
350+
expect(params[17]).toBe('none'); // attribution_method (no fingerprint match)
351+
expect(params[18]).toBeNull(); // matched_factors (no match)
350352
});
351353

352354
it('stores null sdk on the install row when the metadata is absent or empty', async () => {
@@ -356,7 +358,7 @@ describe('recordInstallEvent', () => {
356358
await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, { name: '', version: undefined });
357359

358360
const params = mockDbQuery.mock.calls[1][1];
359-
expect(params[params.length - 2]).toBeNull(); // '' -> null
360-
expect(params[params.length - 1]).toBeNull(); // undefined -> null
361+
expect(params[15]).toBeNull(); // sdk_name '' -> null
362+
expect(params[16]).toBeNull(); // sdk_version undefined -> null
361363
});
362364
});

src/lib/fingerprint.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,12 @@ export async function recordInstallEvent(
400400
// Attempt to match install to a click
401401
const match = await matchInstallToClick(fingerprintData, attributionWindowHours);
402402

403+
// Attribution metadata for measurement (SIT-296): install attribution is
404+
// fingerprint-based, so the method is 'fingerprint' on a match and 'none'
405+
// (organic) otherwise; matched_factors records which signals matched.
406+
const attributionMethod = match ? 'fingerprint' : 'none';
407+
const matchedFactors = match?.matchedFactors ?? null;
408+
403409
// Insert install event
404410
const installResult = await db.query(
405411
`INSERT INTO install_events (
@@ -421,8 +427,10 @@ export async function recordInstallEvent(
421427
device_id,
422428
deep_link_data,
423429
sdk_name,
424-
sdk_version
425-
) VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
430+
sdk_version,
431+
attribution_method,
432+
matched_factors
433+
) VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
426434
RETURNING id, deep_link_data`,
427435
[
428436
match?.linkId || null,
@@ -442,6 +450,8 @@ export async function recordInstallEvent(
442450
match ? JSON.stringify({}) : JSON.stringify({}), // Will be populated from link data
443451
sdk?.name || null,
444452
sdk?.version || null,
453+
attributionMethod,
454+
matchedFactors,
445455
]
446456
);
447457

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* One-time backfill of install_events.attribution_method for rows recorded
3+
* before attribution metadata existed (SIT-296). Historical rows only tell us
4+
* whether they were attributed (link_id present), so this sets:
5+
* - 'fingerprint' when link_id IS NOT NULL (current attribution is fingerprint-based)
6+
* - 'none' otherwise (organic)
7+
* `matched_factors` is left NULL for history — the matched signals weren't
8+
* stored and can't be reconstructed; new installs record it going forward.
9+
*
10+
* Keyset-paginated over the primary key; idempotent (only touches NULL rows).
11+
* Run: `npm run backfill:attribution` (needs DATABASE_URL).
12+
*/
13+
import { db, initializeDatabase } from '../lib/database.js';
14+
15+
const BATCH = 5000;
16+
17+
async function backfill() {
18+
await initializeDatabase();
19+
let cursor = '00000000-0000-0000-0000-000000000000';
20+
let scanned = 0;
21+
let updated = 0;
22+
23+
for (;;) {
24+
const { rows } = await db.query(
25+
`SELECT id FROM install_events WHERE id > $1 ORDER BY id LIMIT $2`,
26+
[cursor, BATCH]
27+
);
28+
if (rows.length === 0) break;
29+
30+
const ids = rows.map((r: { id: string }) => r.id);
31+
const res = await db.query(
32+
`UPDATE install_events
33+
SET attribution_method = CASE WHEN link_id IS NOT NULL THEN 'fingerprint' ELSE 'none' END
34+
WHERE id = ANY($1::uuid[]) AND attribution_method IS NULL`,
35+
[ids]
36+
);
37+
updated += res.rowCount ?? 0;
38+
scanned += rows.length;
39+
cursor = rows[rows.length - 1].id;
40+
console.log(`[backfill-attribution] scanned ${scanned}, set ${updated}`);
41+
}
42+
43+
console.log(`[backfill-attribution] done: scanned ${scanned}, set attribution_method on ${updated}`);
44+
process.exit(0);
45+
}
46+
47+
backfill().catch((error) => {
48+
console.error('[backfill-attribution] failed:', error);
49+
process.exit(1);
50+
});

0 commit comments

Comments
 (0)