diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index e07ba2971..7d6f64ec1 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -10,6 +10,7 @@ import { fetchWebsite } from '@/lib/load'; import { parseRequest } from '@/lib/request'; import { badRequest, forbidden, json, serverError } from '@/lib/response'; import { anyObjectParam, urlOrPathParam } from '@/lib/schema'; +import { getSessionId } from '@/lib/session'; import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url'; import { createSession, saveEvent, saveSessionData, saveSessionLink } from '@/queries/sql'; @@ -155,7 +156,7 @@ export async function POST(request: Request) { const sessionSalt = getSalt(saltRotation, createdAt); const visitSalt = hash(startOfHour(createdAt).toUTCString()); - const sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt); + const sessionId = getSessionId(sourceId, ip, userAgent, sessionSalt, id); // Create a session if not found if (!clickhouse.enabled && !cache?.sessionId) { @@ -284,7 +285,7 @@ export async function POST(request: Request) { if (websiteId && id) { // Persist both memberships so stitched activity can be resolved from // either the anonymous session or the identified session. - const anonymousSessionId = uuid(sourceId, ip, userAgent, sessionSalt); + const anonymousSessionId = getSessionId(sourceId, ip, userAgent, sessionSalt); const linkedSessionIds = [...new Set([anonymousSessionId, sessionId])]; const newLinkId = hash(...linkedSessionIds, id); diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts new file mode 100644 index 000000000..412faa498 --- /dev/null +++ b/src/lib/session.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from 'vitest'; +import { uuid } from './crypto'; +import { getSessionId } from './session'; + +const sourceId = '2e2fc91f-77a7-4e5d-a7b1-18a74c67b4e0'; +const sessionSalt = 'monthly-session-salt'; + +test('keeps identified sessions stable on the same device', () => { + const args = [sourceId, '192.0.2.1', 'Desktop Browser', sessionSalt, 'user-123'] as const; + + expect(getSessionId(...args)).toBe(getSessionId(...args)); +}); + +test('creates separate sessions for the same distinct ID on different devices', () => { + const desktopSessionId = getSessionId( + sourceId, + '192.0.2.1', + 'Desktop Browser', + sessionSalt, + 'user-123', + ); + const mobileSessionId = getSessionId( + sourceId, + '198.51.100.2', + 'Mobile Browser', + sessionSalt, + 'user-123', + ); + + expect(desktopSessionId).not.toBe(mobileSessionId); +}); + +test('does not merge different identified users on the same device', () => { + const firstUserSessionId = getSessionId( + sourceId, + '192.0.2.1', + 'Desktop Browser', + sessionSalt, + 'user-123', + ); + const secondUserSessionId = getSessionId( + sourceId, + '192.0.2.1', + 'Desktop Browser', + sessionSalt, + 'user-456', + ); + + expect(firstUserSessionId).not.toBe(secondUserSessionId); +}); + +test('preserves anonymous session IDs', () => { + expect(getSessionId(sourceId, '192.0.2.1', 'Desktop Browser', sessionSalt)).toBe( + uuid(sourceId, '192.0.2.1', 'Desktop Browser', sessionSalt), + ); +}); diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 000000000..fdfe44631 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,13 @@ +import { uuid } from './crypto'; + +export function getSessionId( + sourceId: string, + ip: string, + userAgent: string, + sessionSalt: string, + distinctId?: string, +) { + return distinctId + ? uuid(sourceId, ip, userAgent, sessionSalt, distinctId) + : uuid(sourceId, ip, userAgent, sessionSalt); +}