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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,19 @@ GET /api/sdk/v1/resolve/:shortCode # Resolve link to deep link data (no redi
GET /api/sdk/v1/health # Health check
```

An event whose `installId` this server no longer has is **recovered, not
refused**: the install is recorded again (marked `recovered`, with no link,
click or confidence score, so it is never mistaken for a fresh attributed
install) and the event is stored against it.

This matters because the id lives in the app's storage for the life of the
install while the server's copy may not: a restore from an older backup, a
manual cleanup, an app build pointed at a fresh database, or an analytics
retention job all leave a live device holding an id nothing resolves. The
bundled SDKs store the id once and never re-register, so without recovery
that device is refused forever with no way back. The 404 that remains carries
`code: "INSTALL_NOT_FOUND"` and `action: "reregister"`.

### Health

```bash
Expand Down
115 changes: 113 additions & 2 deletions src/routes/sdk.event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,124 @@ describe('POST /api/sdk/v1/event — last-click attribution stamp', () => {
await app.close();
});

it('returns 404 when the install does not exist', async () => {
});

/**
* An event whose install is gone.
*
* The install id lives in the app's storage for the life of the install, so a
* missing row is permanent from the device's point of view: refusing the event
* silences that app forever. A deployment that prunes analytics on a retention
* window reaches this the moment an install outlives the window.
*/
describe('POST /api/sdk/v1/event — an install the server no longer has', () => {
beforeEach(() => {
mockQuery.mockReset();
});

const orphanEvent = {
installId: INSTALL_ID,
eventName: 'purchase',
eventData: { value: 12 },
sdkName: 'android',
sdkVersion: '1.3.2',
};

it('records the install again and stores the event against it', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] }); // 1) install lookup: gone
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] }); // 2) recovery insert
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] }); // 3) the event insert

const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: orphanEvent });

expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true });

const recovery = mockQuery.mock.calls[1];
expect(recovery[0]).toMatch(/INSERT INTO install_events/);
// The id the client sent, so the device's next event resolves normally.
expect(recovery[1][0]).toBe(INSTALL_ID);
// A marker, never a real hash: it cannot collide with a fingerprint match.
expect(recovery[1][1]).toBe(`recovered:${INSTALL_ID}`);
expect(recovery[1].slice(2)).toEqual(['android', '1.3.2']);
// Concurrent events for the same install must not collide.
expect(recovery[0]).toMatch(/ON CONFLICT \(id\) DO NOTHING/);

// The event is stored against that install, unattributed.
const insert = mockQuery.mock.calls[2];
expect(insert[0]).toMatch(/INSERT INTO in_app_events/);
expect(insert[1][0]).toBe(INSTALL_ID);

await app.close();
});

it('claims no attribution for a recovered install', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });

const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: orphanEvent });

const sql = mockQuery.mock.calls[1][0] as string;
expect(sql).toMatch(/'recovered'/);
// No link, click or confidence among the columns written: a device we have
// met before is not a new attributed install, and nothing downstream may
// read it as one. (The RETURNING clause reads link_id back; that is not a
// write.)
const columns = sql.slice(sql.indexOf('('), sql.indexOf('VALUES'));
expect(columns).not.toMatch(/link_id|click_id|confidence_score/);

await app.close();
});

it('uses the row a concurrent request created rather than failing', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] }); // lookup: gone
mockQuery.mockResolvedValueOnce({ rows: [] }); // insert: lost the race
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: LINK_ID }] }); // re-read
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] }); // event insert
mockQuery.mockResolvedValueOnce({ rows: [] }); // webhook lookup (link_id set)

const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: orphanEvent });

expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true });

await app.close();
});

it('tells a client what to do when recovery cannot complete', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] }); // lookup: gone
mockQuery.mockResolvedValueOnce({ rows: [] }); // insert: no row
mockQuery.mockResolvedValueOnce({ rows: [] }); // re-read: still nothing

const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: orphanEvent });

expect(res.statusCode).toBe(404);
expect(res.json()).toEqual({
error: 'Install event not found',
code: 'INSTALL_NOT_FOUND',
action: 'reregister',
});

await app.close();
});

it('leaves a known install untouched', async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });

const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: orphanEvent });

expect(res.statusCode).toBe(200);
// Two queries only: the lookup and the event. No recovery insert.
expect(mockQuery.mock.calls).toHaveLength(2);
expect(mockQuery.mock.calls[1][0]).toMatch(/INSERT INTO in_app_events/);

await app.close();
});
});
72 changes: 68 additions & 4 deletions src/routes/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,59 @@ import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js
import { emitClickEvent } from '../lib/event-emitter.js';
import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js';

/**
* An event arrived for an install this server does not have. Put the install
* back rather than refusing the event.
*
* The id lives in the app's own storage for the life of the install while our
* copy may not, so a missing row is not a transient condition: every event
* that device will ever send refers to an id we cannot resolve, and the SDKs
* store the id once and never re-register, so refusing them means the app goes
* silent forever with no way back. A row goes missing in ordinary operation —
* a restore from an older backup, a manual cleanup, a build pointed at a fresh
* database, an analytics retention job — and none of those are the device's
* fault.
*
* The recovered row is deliberately thin. We know the device exists and which
* SDK it runs; we do not know what brought it here, and we must not invent
* that: `attribution_method` says `recovered` and there is no link, click or
* confidence score, so nothing downstream can mistake it for a fresh
* attributed install. The fingerprint is a marker rather than a hash — it can
* never equal a real one, so it cannot match a click by accident.
*
* Creating a row from a client-supplied id adds no exposure that
* `POST /api/sdk/v1/install` does not already have: that endpoint is
* unauthenticated and creates an install row for anyone who calls it. The only
* difference here is who chose the id, and a UUID the caller picked is still
* only ever their own row.
*
* Returns the row, or null when a concurrent request already created it and we
* lost the race but cannot read it back — the caller then answers 404 as
* before, and the SDK's next event succeeds.
*/
async function recoverInstall(
installId: string,
sdkName?: string,
sdkVersion?: string
): Promise<{ id: string; link_id: string | null } | null> {
const inserted = await db.query(
`INSERT INTO install_events
(id, fingerprint_hash, attribution_method, installed_at, first_open_at, sdk_name, sdk_version)
VALUES ($1, $2, 'recovered', NOW(), NOW(), $3, $4)
ON CONFLICT (id) DO NOTHING
RETURNING id, link_id`,
[installId, `recovered:${installId}`, sdkName || null, sdkVersion || null]
);
if (inserted.rows[0]) return inserted.rows[0];

// Lost the race with a concurrent event for the same install.
const existing = await db.query(
`SELECT id, link_id FROM install_events WHERE id = $1`,
[installId]
);
return existing.rows[0] ?? null;
}

/**
* SDK Routes - Mobile SDK endpoints for deferred deep linking
* These endpoints are used by the mobile SDKs to report installs and retrieve attribution data
Expand Down Expand Up @@ -228,6 +281,13 @@ export async function sdkRoutes(fastify: FastifyInstance) {
* Response:
* - eventId: UUID of the tracked event
* - acknowledged: Boolean confirmation
*
* An `installId` this server no longer has is recovered rather than
* refused: the install is recorded again, marked `recovered`, and the event
* is stored against it. See recoverInstall above. A 404 with
* `code: 'INSTALL_NOT_FOUND'` and `action: 'reregister'` is returned only in
* the rare case where recovery itself could not complete; an SDK seeing it
* should register a fresh install rather than retry the same id.
*/
fastify.post('/api/sdk/v1/event', async (request, reply) => {
const schema = z.object({
Expand All @@ -249,19 +309,23 @@ export async function sdkRoutes(fastify: FastifyInstance) {
const body = schema.parse(request.body);

try {
// Verify install exists and get link_id for webhook lookup
// The install this event belongs to, recovered if we no longer have it.
const installCheck = await db.query(
`SELECT id, link_id FROM install_events WHERE id = $1`,
[body.installId]
);

if (installCheck.rows.length === 0) {
const install =
installCheck.rows[0] ??
(await recoverInstall(body.installId, body.sdkName, body.sdkVersion));

if (!install) {
return reply.status(404).send({
error: 'Install event not found',
code: 'INSTALL_NOT_FOUND',
action: 'reregister',
});
}

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

Expand Down
Loading