Skip to content

Commit 24dff3e

Browse files
authored
feat(sdk): persist last-click attribution on in-app events (SIT-237) (#27)
* feat(sdk): persist last-click attribution on in-app events Attribute in-app events (screen views + custom events) to the deep link that drove them, not just the original install link — the basis for re-engagement and down-funnel reporting (SIT-237). - in_app_events: add nullable attributed_link_id / attributed_click_id / attributed_at / session_id via idempotent startup ALTERs, plus indexes for per-link conversion aggregation and per-session screen-flow lookups. - POST /api/sdk/v1/event accepts and stores the SDK attribution stamp (attributedLinkId/attributedClickId/linkOpenedAt/sessionId). Optional and backward compatible: older clients omit them; a stale/unknown attributed link falls back to a no-link insert so events are never lost. - Route tests: stamp persistence, backward compat, FK fallback, 404. Cloud inherits both schema and ingestion via @linkforty/core (initializeDatabase + sdkRoutes). Per-workspace windows and windowed aggregation are cloud-side. * fix(sdk): scope the in-app-event FK fallback to the attributed-link FK only Address PR review on the last-click attribution insert: - Narrow the 23503 catch to the attributed_link_id FK (via insertError.constraint). A non-link 23503 — e.g. install_id's FK lost to a concurrent install delete — now rethrows as a real error instead of being mislabeled "attributed_link_id not found" and then re-failing the fallback (which still carries install_id) with an uncaught 500. - Comment the intentional orphaned-click case (fallback keeps attributed_click_id with a null link; null link is correct for SIT-261 link-keyed aggregation) and the keep-in-sync requirement of the duplicated fallback INSERT. - Make idx_in_app_events_session a partial index (WHERE session_id IS NOT NULL): session_id is null for the legacy/organic majority and screen-flow lookups always filter non-null. Done before first creation to avoid a later DROP+CREATE. Adds a test for the non-link FK rethrow path; FK-fallback test now mocks the link constraint name. 130 tests green, tsc clean.
1 parent c18ee3d commit 24dff3e

3 files changed

Lines changed: 246 additions & 12 deletions

File tree

src/lib/database.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,30 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
370370
END $$;
371371
`);
372372

373+
// Last-click attribution columns on in_app_events (SIT-237).
374+
// Events (screen views + custom events) are attributed to the deep link that
375+
// drove them, not just the original install link. The SDK stamps each event
376+
// with the active link, when it opened, and the app-open session; the window
377+
// (organic vs attributed) is applied at query time. Nullable + backward
378+
// compatible: legacy/organic rows stay null and fall back to the install link.
379+
await client.query(`
380+
DO $$
381+
BEGIN
382+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='attributed_link_id') THEN
383+
ALTER TABLE in_app_events ADD COLUMN attributed_link_id UUID REFERENCES links(id) ON DELETE SET NULL;
384+
END IF;
385+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='attributed_click_id') THEN
386+
ALTER TABLE in_app_events ADD COLUMN attributed_click_id UUID;
387+
END IF;
388+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='attributed_at') THEN
389+
ALTER TABLE in_app_events ADD COLUMN attributed_at TIMESTAMP;
390+
END IF;
391+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='session_id') THEN
392+
ALTER TABLE in_app_events ADD COLUMN session_id UUID;
393+
END IF;
394+
END $$;
395+
`);
396+
373397
// Create indexes for performance
374398
await client.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_links_short_code ON links(short_code)');
375399
await client.query('CREATE INDEX IF NOT EXISTS idx_links_user_id ON links(user_id)');
@@ -398,6 +422,11 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
398422
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_install_id ON in_app_events(install_id)');
399423
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_name ON in_app_events(event_name)');
400424
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_timestamp ON in_app_events(event_timestamp DESC)');
425+
// Attribution lookups: per-link conversion aggregation + per-session screen flow
426+
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_attributed_link ON in_app_events(attributed_link_id, event_timestamp DESC)');
427+
// Partial: session_id is null for legacy/organic in-app events (the majority);
428+
// per-session screen-flow lookups always filter `session_id IS NOT NULL`.
429+
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_session ON in_app_events(session_id) WHERE session_id IS NOT NULL');
401430

402431
// Add deep_link_parameters column for custom deep link parameters
403432
await client.query(`

src/routes/sdk.event.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
2+
3+
// Mock the database singleton so the route runs without a real Postgres.
4+
vi.mock('../lib/database.js', () => ({
5+
db: { query: vi.fn() },
6+
}));
7+
8+
import Fastify, { type FastifyInstance } from 'fastify';
9+
import { db } from '../lib/database.js';
10+
import { sdkRoutes } from './sdk.js';
11+
12+
const mockQuery = db.query as unknown as Mock;
13+
14+
const INSTALL_ID = '11111111-1111-4111-8111-111111111111';
15+
const LINK_ID = '22222222-2222-4222-8222-222222222222';
16+
const SESSION_ID = '33333333-3333-4333-8333-333333333333';
17+
const CLICK_ID = '44444444-4444-4444-8444-444444444444';
18+
const EVENT_ID = '55555555-5555-4555-8555-555555555555';
19+
20+
async function buildApp(): Promise<FastifyInstance> {
21+
const app = Fastify();
22+
await app.register(sdkRoutes);
23+
await app.ready();
24+
return app;
25+
}
26+
27+
const EVENT_TS = '2026-06-05T10:05:00.000Z';
28+
const LINK_OPENED_AT = '2026-06-05T10:00:00.000Z';
29+
30+
const stampedEvent = {
31+
installId: INSTALL_ID,
32+
eventName: 'add_to_cart',
33+
eventData: { sku: 'abc' },
34+
timestamp: EVENT_TS,
35+
attributedLinkId: LINK_ID,
36+
attributedClickId: CLICK_ID,
37+
linkOpenedAt: LINK_OPENED_AT,
38+
sessionId: SESSION_ID,
39+
};
40+
41+
describe('POST /api/sdk/v1/event — last-click attribution stamp', () => {
42+
beforeEach(() => {
43+
mockQuery.mockReset();
44+
});
45+
46+
it('persists the attribution stamp on the in_app_events row', async () => {
47+
// 1) install lookup (link_id null so the webhook block is skipped)
48+
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
49+
// 2) the event INSERT
50+
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
51+
52+
const app = await buildApp();
53+
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
54+
55+
expect(res.statusCode).toBe(200);
56+
expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true });
57+
58+
const insertCall = mockQuery.mock.calls[1];
59+
expect(insertCall[0]).toMatch(/INSERT INTO in_app_events/);
60+
expect(insertCall[0]).toMatch(/attributed_link_id/);
61+
// params: install, name, dataJson, ts, link, click, openedAt, session
62+
expect(insertCall[1]).toEqual([
63+
INSTALL_ID,
64+
'add_to_cart',
65+
JSON.stringify({ sku: 'abc' }),
66+
EVENT_TS,
67+
LINK_ID,
68+
CLICK_ID,
69+
LINK_OPENED_AT,
70+
SESSION_ID,
71+
]);
72+
73+
await app.close();
74+
});
75+
76+
it('stays backward compatible: an event with no stamp stores null attribution + a sessionId-less row', async () => {
77+
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
78+
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
79+
80+
const app = await buildApp();
81+
const res = await app.inject({
82+
method: 'POST',
83+
url: '/api/sdk/v1/event',
84+
payload: { installId: INSTALL_ID, eventName: 'signup' },
85+
});
86+
87+
expect(res.statusCode).toBe(200);
88+
const params = mockQuery.mock.calls[1][1];
89+
expect(params[4]).toBeNull(); // attributed_link_id
90+
expect(params[5]).toBeNull(); // attributed_click_id
91+
expect(params[6]).toBeNull(); // attributed_at
92+
expect(params[7]).toBeNull(); // session_id
93+
await app.close();
94+
});
95+
96+
it('never loses an event when the attributed link is stale: falls back to no-link insert on FK violation', async () => {
97+
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
98+
// first INSERT rejects with the attributed_link_id FK violation
99+
mockQuery.mockRejectedValueOnce(Object.assign(new Error('FK violation'), { code: '23503', constraint: 'in_app_events_attributed_link_id_fkey' }));
100+
// fallback INSERT (without attributed_link_id) succeeds
101+
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
102+
103+
const app = await buildApp();
104+
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
105+
106+
expect(res.statusCode).toBe(200);
107+
expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true });
108+
109+
// the fallback INSERT omits attributed_link_id but keeps click/session
110+
const fallbackCall = mockQuery.mock.calls[2];
111+
expect(fallbackCall[0]).not.toMatch(/attributed_link_id/);
112+
expect(fallbackCall[1]).toEqual([
113+
INSTALL_ID,
114+
'add_to_cart',
115+
JSON.stringify({ sku: 'abc' }),
116+
EVENT_TS,
117+
CLICK_ID,
118+
LINK_OPENED_AT,
119+
SESSION_ID,
120+
]);
121+
122+
await app.close();
123+
});
124+
125+
it('rethrows a non-link FK violation (e.g. install deleted mid-request) instead of mislabeling it as a link problem', async () => {
126+
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
127+
// install_id's FK lost to a concurrent install delete — a 23503 that is NOT the link FK
128+
mockQuery.mockRejectedValueOnce(Object.assign(new Error('FK violation'), { code: '23503', constraint: 'in_app_events_install_id_fkey' }));
129+
130+
const app = await buildApp();
131+
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
132+
133+
// Surfaces as a real error; no misleading no-link fallback insert is attempted.
134+
expect(res.statusCode).toBe(500);
135+
expect(mockQuery.mock.calls.length).toBe(2); // install lookup + the failed insert only
136+
await app.close();
137+
});
138+
139+
it('returns 404 when the install does not exist', async () => {
140+
mockQuery.mockResolvedValueOnce({ rows: [] });
141+
142+
const app = await buildApp();
143+
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
144+
145+
expect(res.statusCode).toBe(404);
146+
await app.close();
147+
});
148+
});

src/routes/sdk.ts

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,12 @@ export async function sdkRoutes(fastify: FastifyInstance) {
210210
* - eventData: Optional JSON data associated with the event
211211
* - timestamp: Optional event timestamp (defaults to now)
212212
*
213+
* Last-click attribution stamp (SIT-237, all optional / backward compatible):
214+
* - attributedLinkId: UUID of the deep link currently credited (last-click)
215+
* - attributedClickId: UUID of the originating click, when known
216+
* - linkOpenedAt: ISO timestamp of when that deep link opened the app
217+
* - sessionId: UUID identifying the app-open session (for screen-flow grouping)
218+
*
213219
* Response:
214220
* - eventId: UUID of the tracked event
215221
* - acknowledged: Boolean confirmation
@@ -220,6 +226,10 @@ export async function sdkRoutes(fastify: FastifyInstance) {
220226
eventName: z.string(),
221227
eventData: z.record(z.any()).optional(),
222228
timestamp: z.string().datetime().optional(),
229+
attributedLinkId: z.string().uuid().optional(),
230+
attributedClickId: z.string().uuid().optional(),
231+
linkOpenedAt: z.string().datetime().optional(),
232+
sessionId: z.string().uuid().optional(),
223233
});
224234

225235
const body = schema.parse(request.body);
@@ -239,19 +249,66 @@ export async function sdkRoutes(fastify: FastifyInstance) {
239249

240250
const install = installCheck.rows[0];
241251
const eventTimestamp = body.timestamp || new Date().toISOString();
252+
const eventDataJson = JSON.stringify(body.eventData || {});
242253

243-
// Insert event into in_app_events table
244-
const eventResult = await db.query(
245-
`INSERT INTO in_app_events (install_id, event_name, event_data, event_timestamp)
246-
VALUES ($1, $2, $3, $4)
247-
RETURNING id`,
248-
[
249-
body.installId,
250-
body.eventName,
251-
JSON.stringify(body.eventData || {}),
252-
eventTimestamp,
253-
]
254-
);
254+
// Insert event with the last-click attribution stamp. The attributing link
255+
// may differ from the install link (re-engagement) or be absent (organic).
256+
let eventResult;
257+
try {
258+
eventResult = await db.query(
259+
`INSERT INTO in_app_events
260+
(install_id, event_name, event_data, event_timestamp,
261+
attributed_link_id, attributed_click_id, attributed_at, session_id)
262+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
263+
RETURNING id`,
264+
[
265+
body.installId,
266+
body.eventName,
267+
eventDataJson,
268+
eventTimestamp,
269+
body.attributedLinkId ?? null,
270+
body.attributedClickId ?? null,
271+
body.linkOpenedAt ?? null,
272+
body.sessionId ?? null,
273+
]
274+
);
275+
} catch (insertError: any) {
276+
// Only a stale/unknown *attributed link* FK is recoverable here: record
277+
// the event without link attribution rather than losing it. Any other
278+
// 23503 — e.g. install_id's FK lost to a concurrent install delete — is a
279+
// real error that must surface, not be mislabeled as a link problem.
280+
const isLinkFk =
281+
insertError?.code === '23503' &&
282+
String(insertError?.constraint ?? '').includes('attributed_link_id');
283+
if (isLinkFk) {
284+
fastify.log.warn(
285+
`attributed_link_id ${body.attributedLinkId} not found; storing event without link attribution`
286+
);
287+
// Keep this column list in sync with the primary INSERT above (it just
288+
// omits attributed_link_id). attributed_click_id is intentionally kept
289+
// without a link: the orphaned-click case is expected, and a null
290+
// attributed_link_id is the correct value for link-keyed aggregation
291+
// (the SIT-261 consumer must not read it as a data bug).
292+
eventResult = await db.query(
293+
`INSERT INTO in_app_events
294+
(install_id, event_name, event_data, event_timestamp,
295+
attributed_click_id, attributed_at, session_id)
296+
VALUES ($1, $2, $3, $4, $5, $6, $7)
297+
RETURNING id`,
298+
[
299+
body.installId,
300+
body.eventName,
301+
eventDataJson,
302+
eventTimestamp,
303+
body.attributedClickId ?? null,
304+
body.linkOpenedAt ?? null,
305+
body.sessionId ?? null,
306+
]
307+
);
308+
} else {
309+
throw insertError;
310+
}
311+
}
255312

256313
const eventId = eventResult.rows[0].id;
257314

0 commit comments

Comments
 (0)