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
27 changes: 27 additions & 0 deletions src/lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
Expand Down
30 changes: 30 additions & 0 deletions src/lib/fingerprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
11 changes: 8 additions & 3 deletions src/lib/fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
]
);

Expand Down
6 changes: 6 additions & 0 deletions src/routes/sdk.event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
27 changes: 22 additions & 5 deletions src/routes/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
Loading