diff --git a/src/lib/database.ts b/src/lib/database.ts index b2faee3..0ff14ae 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -394,6 +394,33 @@ export async function initializeDatabase(options: DatabaseOptions = {}) { END $$; `); + // SDK identity columns (SIT-235) — name + version of the SDK that sent the + // install/event, for SDK version diagnostics. Persisted on BOTH tables: + // install_events (version at install time) and in_app_events because an app + // that updates keeps its original install row but sends events with the new + // version — so version-fragmentation / outdated-version checks must read from + // the event stream. Nullable + backward compatible (older SDKs omit them). + // NOTE: 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. + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='sdk_name') THEN + ALTER TABLE install_events ADD COLUMN sdk_name VARCHAR(50); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='sdk_version') THEN + ALTER TABLE install_events ADD COLUMN sdk_version VARCHAR(50); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='sdk_name') THEN + ALTER TABLE in_app_events ADD COLUMN sdk_name VARCHAR(50); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='sdk_version') THEN + ALTER TABLE in_app_events ADD COLUMN sdk_version VARCHAR(50); + END IF; + END $$; + `); + // Create indexes for performance await client.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_links_short_code ON links(short_code)'); await client.query('CREATE INDEX IF NOT EXISTS idx_links_user_id ON links(user_id)'); diff --git a/src/lib/fingerprint.test.ts b/src/lib/fingerprint.test.ts index aab7a0c..d2b3431 100644 --- a/src/lib/fingerprint.test.ts +++ b/src/lib/fingerprint.test.ts @@ -329,4 +329,34 @@ describe('recordInstallEvent', () => { expect.arrayContaining([null, null, expect.any(String), null, expect.any(String), expect.any(String), null, null, null, null, null, null, null, 'device-1', null]) ); }); + + it('forwards sdk name + version as the last two positional params of the install insert (guards column misalignment)', async () => { + mockDbQuery.mockResolvedValueOnce({ rows: [] }); // matchInstallToClick -> no match + mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-456', deep_link_data: {} }] }); + + await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, { + name: 'react-native', + version: '1.4.0', + }); + + const [sql, params] = mockDbQuery.mock.calls[1]; + 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'); + }); + + it('stores null sdk on the install row when the metadata is absent or empty', async () => { + mockDbQuery.mockResolvedValueOnce({ rows: [] }); + mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-789', deep_link_data: {} }] }); + + 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 + }); }); diff --git a/src/lib/fingerprint.ts b/src/lib/fingerprint.ts index 370b694..063e5f9 100644 --- a/src/lib/fingerprint.ts +++ b/src/lib/fingerprint.ts @@ -388,7 +388,8 @@ export async function storeFingerprintForClick( export async function recordInstallEvent( fingerprintData: FingerprintData, deviceId?: string, - attributionWindowHours: number = DEFAULT_ATTRIBUTION_WINDOW_HOURS + attributionWindowHours: number = DEFAULT_ATTRIBUTION_WINDOW_HOURS, + sdk?: { name?: string | null; version?: string | null } ): Promise<{ installId: string; match: FingerprintMatch | null; @@ -418,8 +419,10 @@ export async function recordInstallEvent( platform, platform_version, device_id, - deep_link_data - ) VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + 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) RETURNING id, deep_link_data`, [ match?.linkId || null, @@ -437,6 +440,8 @@ export async function recordInstallEvent( fingerprintData.platformVersion || null, deviceId || null, match ? JSON.stringify({}) : JSON.stringify({}), // Will be populated from link data + sdk?.name || null, + sdk?.version || null, ] ); diff --git a/src/routes/sdk.event.test.ts b/src/routes/sdk.event.test.ts index 962be12..125bd09 100644 --- a/src/routes/sdk.event.test.ts +++ b/src/routes/sdk.event.test.ts @@ -36,6 +36,8 @@ const stampedEvent = { attributedClickId: CLICK_ID, linkOpenedAt: LINK_OPENED_AT, sessionId: SESSION_ID, + sdkName: 'react-native', + sdkVersion: '1.4.0', }; describe('POST /api/sdk/v1/event — last-click attribution stamp', () => { @@ -68,6 +70,8 @@ describe('POST /api/sdk/v1/event — last-click attribution stamp', () => { CLICK_ID, LINK_OPENED_AT, SESSION_ID, + 'react-native', + '1.4.0', ]); await app.close(); @@ -117,6 +121,8 @@ describe('POST /api/sdk/v1/event — last-click attribution stamp', () => { CLICK_ID, LINK_OPENED_AT, SESSION_ID, + 'react-native', + '1.4.0', ]); await app.close(); diff --git a/src/routes/sdk.ts b/src/routes/sdk.ts index e8552e4..51fc343 100644 --- a/src/routes/sdk.ts +++ b/src/routes/sdk.ts @@ -52,6 +52,11 @@ export async function sdkRoutes(fastify: FastifyInstance) { platformVersion: z.string().optional(), deviceId: z.string().optional(), attributionWindowHours: z.number().optional(), + // SDK identity for health/version diagnostics (SIT-235). Free-form by + // design: a consumer tolerates/normalizes non-semver versions — we never + // reject a request over this metadata. Empty → null. + sdkName: z.string().max(50).optional(), + sdkVersion: z.string().max(50).optional(), // Public app token shipped in SDK app bundles to scope organic // installs to the right org in multi-tenant deployments. Used by // Cloud's onSend hook (see cloud-event-hook.ts). Self-hosted @@ -79,7 +84,8 @@ export async function sdkRoutes(fastify: FastifyInstance) { const result = await recordInstallEvent( fingerprintData, body.deviceId, - body.attributionWindowHours + body.attributionWindowHours, + { name: body.sdkName, version: body.sdkVersion } ); return reply.status(200).send({ @@ -230,6 +236,11 @@ export async function sdkRoutes(fastify: FastifyInstance) { attributedClickId: z.string().uuid().optional(), linkOpenedAt: z.string().datetime().optional(), sessionId: z.string().uuid().optional(), + // SDK identity for version-health diagnostics (SIT-235). Free-form by + // design: a consumer tolerates/normalizes non-semver versions — we never + // reject a request over this metadata. Empty → null. + sdkName: z.string().max(50).optional(), + sdkVersion: z.string().max(50).optional(), }); const body = schema.parse(request.body); @@ -258,8 +269,9 @@ export async function sdkRoutes(fastify: FastifyInstance) { eventResult = await db.query( `INSERT INTO in_app_events (install_id, event_name, event_data, event_timestamp, - attributed_link_id, attributed_click_id, attributed_at, session_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + attributed_link_id, attributed_click_id, attributed_at, session_id, + sdk_name, sdk_version) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, [ body.installId, @@ -270,6 +282,8 @@ export async function sdkRoutes(fastify: FastifyInstance) { body.attributedClickId ?? null, body.linkOpenedAt ?? null, body.sessionId ?? null, + body.sdkName || null, + body.sdkVersion || null, ] ); } catch (insertError: any) { @@ -292,8 +306,9 @@ export async function sdkRoutes(fastify: FastifyInstance) { eventResult = await db.query( `INSERT INTO in_app_events (install_id, event_name, event_data, event_timestamp, - attributed_click_id, attributed_at, session_id) - VALUES ($1, $2, $3, $4, $5, $6, $7) + attributed_click_id, attributed_at, session_id, + sdk_name, sdk_version) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, [ body.installId, @@ -303,6 +318,8 @@ export async function sdkRoutes(fastify: FastifyInstance) { body.attributedClickId ?? null, body.linkOpenedAt ?? null, body.sessionId ?? null, + body.sdkName || null, + body.sdkVersion || null, ] ); } else {