Skip to content

Commit c38f10f

Browse files
authored
feat(sdk): capture SDK name + version on install and events (#28)
Persist sdkName/sdkVersion (sent by the SDKs) on install_events and in_app_events for SDK version diagnostics. Backward compatible — older SDKs omit the fields, which store null. - /install and /event accept optional sdkName/sdkVersion (free-form, max 50; non-semver is tolerated downstream — never reject a request over this metadata). - recordInstallEvent forwards them as the last two positional params of the install_events INSERT; tests pin those positions (guarding column drift) and cover the empty/absent -> null case. - Empty values normalize to null consistently (|| null) on both rows. - No index on sdk_name/sdk_version yet: deferred until a consumer aggregates them (e.g. "installs by version"), so the index can match the real query shape.
1 parent 2e087d8 commit c38f10f

5 files changed

Lines changed: 93 additions & 8 deletions

File tree

src/lib/database.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,33 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
394394
END $$;
395395
`);
396396

397+
// SDK identity columns (SIT-235) — name + version of the SDK that sent the
398+
// install/event, for SDK version diagnostics. Persisted on BOTH tables:
399+
// install_events (version at install time) and in_app_events because an app
400+
// that updates keeps its original install row but sends events with the new
401+
// version — so version-fragmentation / outdated-version checks must read from
402+
// the event stream. Nullable + backward compatible (older SDKs omit them).
403+
// NOTE: no index on sdk_name/sdk_version yet — deferred until a consumer
404+
// aggregates them (e.g. "installs by version"), so the index can match the
405+
// real query shape.
406+
await client.query(`
407+
DO $$
408+
BEGIN
409+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='sdk_name') THEN
410+
ALTER TABLE install_events ADD COLUMN sdk_name VARCHAR(50);
411+
END IF;
412+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='sdk_version') THEN
413+
ALTER TABLE install_events ADD COLUMN sdk_version VARCHAR(50);
414+
END IF;
415+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='sdk_name') THEN
416+
ALTER TABLE in_app_events ADD COLUMN sdk_name VARCHAR(50);
417+
END IF;
418+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='sdk_version') THEN
419+
ALTER TABLE in_app_events ADD COLUMN sdk_version VARCHAR(50);
420+
END IF;
421+
END $$;
422+
`);
423+
397424
// Create indexes for performance
398425
await client.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_links_short_code ON links(short_code)');
399426
await client.query('CREATE INDEX IF NOT EXISTS idx_links_user_id ON links(user_id)');

src/lib/fingerprint.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,4 +329,34 @@ describe('recordInstallEvent', () => {
329329
expect.arrayContaining([null, null, expect.any(String), null, expect.any(String), expect.any(String), null, null, null, null, null, null, null, 'device-1', null])
330330
);
331331
});
332+
333+
it('forwards sdk name + version as the last two positional params of the install insert (guards column misalignment)', async () => {
334+
mockDbQuery.mockResolvedValueOnce({ rows: [] }); // matchInstallToClick -> no match
335+
mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-456', deep_link_data: {} }] });
336+
337+
await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, {
338+
name: 'react-native',
339+
version: '1.4.0',
340+
});
341+
342+
const [sql, params] = mockDbQuery.mock.calls[1];
343+
expect(sql).toMatch(/INSERT INTO install_events/);
344+
expect(sql).toMatch(/sdk_name/);
345+
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');
350+
});
351+
352+
it('stores null sdk on the install row when the metadata is absent or empty', async () => {
353+
mockDbQuery.mockResolvedValueOnce({ rows: [] });
354+
mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-789', deep_link_data: {} }] });
355+
356+
await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, { name: '', version: undefined });
357+
358+
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+
});
332362
});

src/lib/fingerprint.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,8 @@ export async function storeFingerprintForClick(
388388
export async function recordInstallEvent(
389389
fingerprintData: FingerprintData,
390390
deviceId?: string,
391-
attributionWindowHours: number = DEFAULT_ATTRIBUTION_WINDOW_HOURS
391+
attributionWindowHours: number = DEFAULT_ATTRIBUTION_WINDOW_HOURS,
392+
sdk?: { name?: string | null; version?: string | null }
392393
): Promise<{
393394
installId: string;
394395
match: FingerprintMatch | null;
@@ -418,8 +419,10 @@ export async function recordInstallEvent(
418419
platform,
419420
platform_version,
420421
device_id,
421-
deep_link_data
422-
) VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
422+
deep_link_data,
423+
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)
423426
RETURNING id, deep_link_data`,
424427
[
425428
match?.linkId || null,
@@ -437,6 +440,8 @@ export async function recordInstallEvent(
437440
fingerprintData.platformVersion || null,
438441
deviceId || null,
439442
match ? JSON.stringify({}) : JSON.stringify({}), // Will be populated from link data
443+
sdk?.name || null,
444+
sdk?.version || null,
440445
]
441446
);
442447

src/routes/sdk.event.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const stampedEvent = {
3636
attributedClickId: CLICK_ID,
3737
linkOpenedAt: LINK_OPENED_AT,
3838
sessionId: SESSION_ID,
39+
sdkName: 'react-native',
40+
sdkVersion: '1.4.0',
3941
};
4042

4143
describe('POST /api/sdk/v1/event — last-click attribution stamp', () => {
@@ -68,6 +70,8 @@ describe('POST /api/sdk/v1/event — last-click attribution stamp', () => {
6870
CLICK_ID,
6971
LINK_OPENED_AT,
7072
SESSION_ID,
73+
'react-native',
74+
'1.4.0',
7175
]);
7276

7377
await app.close();
@@ -117,6 +121,8 @@ describe('POST /api/sdk/v1/event — last-click attribution stamp', () => {
117121
CLICK_ID,
118122
LINK_OPENED_AT,
119123
SESSION_ID,
124+
'react-native',
125+
'1.4.0',
120126
]);
121127

122128
await app.close();

src/routes/sdk.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export async function sdkRoutes(fastify: FastifyInstance) {
5252
platformVersion: z.string().optional(),
5353
deviceId: z.string().optional(),
5454
attributionWindowHours: z.number().optional(),
55+
// SDK identity for health/version diagnostics (SIT-235). Free-form by
56+
// design: a consumer tolerates/normalizes non-semver versions — we never
57+
// reject a request over this metadata. Empty → null.
58+
sdkName: z.string().max(50).optional(),
59+
sdkVersion: z.string().max(50).optional(),
5560
// Public app token shipped in SDK app bundles to scope organic
5661
// installs to the right org in multi-tenant deployments. Used by
5762
// Cloud's onSend hook (see cloud-event-hook.ts). Self-hosted
@@ -79,7 +84,8 @@ export async function sdkRoutes(fastify: FastifyInstance) {
7984
const result = await recordInstallEvent(
8085
fingerprintData,
8186
body.deviceId,
82-
body.attributionWindowHours
87+
body.attributionWindowHours,
88+
{ name: body.sdkName, version: body.sdkVersion }
8389
);
8490

8591
return reply.status(200).send({
@@ -230,6 +236,11 @@ export async function sdkRoutes(fastify: FastifyInstance) {
230236
attributedClickId: z.string().uuid().optional(),
231237
linkOpenedAt: z.string().datetime().optional(),
232238
sessionId: z.string().uuid().optional(),
239+
// SDK identity for version-health diagnostics (SIT-235). Free-form by
240+
// design: a consumer tolerates/normalizes non-semver versions — we never
241+
// reject a request over this metadata. Empty → null.
242+
sdkName: z.string().max(50).optional(),
243+
sdkVersion: z.string().max(50).optional(),
233244
});
234245

235246
const body = schema.parse(request.body);
@@ -258,8 +269,9 @@ export async function sdkRoutes(fastify: FastifyInstance) {
258269
eventResult = await db.query(
259270
`INSERT INTO in_app_events
260271
(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)
272+
attributed_link_id, attributed_click_id, attributed_at, session_id,
273+
sdk_name, sdk_version)
274+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
263275
RETURNING id`,
264276
[
265277
body.installId,
@@ -270,6 +282,8 @@ export async function sdkRoutes(fastify: FastifyInstance) {
270282
body.attributedClickId ?? null,
271283
body.linkOpenedAt ?? null,
272284
body.sessionId ?? null,
285+
body.sdkName || null,
286+
body.sdkVersion || null,
273287
]
274288
);
275289
} catch (insertError: any) {
@@ -292,8 +306,9 @@ export async function sdkRoutes(fastify: FastifyInstance) {
292306
eventResult = await db.query(
293307
`INSERT INTO in_app_events
294308
(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)
309+
attributed_click_id, attributed_at, session_id,
310+
sdk_name, sdk_version)
311+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
297312
RETURNING id`,
298313
[
299314
body.installId,
@@ -303,6 +318,8 @@ export async function sdkRoutes(fastify: FastifyInstance) {
303318
body.attributedClickId ?? null,
304319
body.linkOpenedAt ?? null,
305320
body.sessionId ?? null,
321+
body.sdkName || null,
322+
body.sdkVersion || null,
306323
]
307324
);
308325
} else {

0 commit comments

Comments
 (0)