From 188b628cf050a63884c686b39af7de7235d83645 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Tue, 15 Sep 2026 11:46:08 -0700 Subject: [PATCH] fix: separate identified sessions by distinct id --- src/app/(main)/TopNav.tsx | 3 -- .../[websiteId]/sessions/SessionInfo.tsx | 7 ++- src/app/api/send/route.test.ts | 51 ++++++++++++++++++- src/app/api/send/route.ts | 22 ++++---- .../[sessionId]/activity/route.test.ts | 30 ++++++++++- .../sessions/[sessionId]/activity/route.ts | 10 ++-- .../sessions/[sessionId]/route.test.ts | 27 ++++++++++ .../[websiteId]/sessions/[sessionId]/route.ts | 24 +++++---- src/queries/sql/sessions/updateSession.ts | 2 +- 9 files changed, 141 insertions(+), 35 deletions(-) diff --git a/src/app/(main)/TopNav.tsx b/src/app/(main)/TopNav.tsx index ef42c73ef..2b41e144a 100644 --- a/src/app/(main)/TopNav.tsx +++ b/src/app/(main)/TopNav.tsx @@ -71,7 +71,6 @@ export function TopNav() { teamId={teamId} onChange={handleLinkChange} buttonProps={{ - variant: 'quiet', style: { minHeight: 40, minWidth: 200, maxWidth: 200 }, }} /> @@ -82,7 +81,6 @@ export function TopNav() { teamId={teamId} onChange={handlePixelChange} buttonProps={{ - variant: 'quiet', style: { minHeight: 40, minWidth: 200, maxWidth: 200 }, }} /> @@ -93,7 +91,6 @@ export function TopNav() { teamId={teamId} onChange={handleBoardChange} buttonProps={{ - variant: 'quiet', style: { minHeight: 40, minWidth: 200, maxWidth: 200 }, }} /> diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx index 39e90ca9a..669dd965e 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx @@ -11,7 +11,10 @@ export function SessionInfo({ data }) { const { t, labels } = useMessages(); const { formatValue } = useFormat(); const { getRegionName } = useRegionNames(locale); - const distinctId = data?.distinctId?.trim(); + const distinctId = + data?.distinctIds?.length > 1 + ? `${data.distinctIds.length} linked IDs` + : data?.distinctId?.trim(); const stitchedSessionCount = data?.stitchedSessionCount; return ( @@ -56,7 +59,7 @@ export function SessionInfo({ data }) { {distinctId && stitchedSessionCount > 1 && ( - }> + }> {stitchedSessionCount} )} diff --git a/src/app/api/send/route.test.ts b/src/app/api/send/route.test.ts index deaad9e45..3a55bfbd8 100644 --- a/src/app/api/send/route.test.ts +++ b/src/app/api/send/route.test.ts @@ -93,11 +93,15 @@ function callPOST( ); } -function makeComputedSessionId(sourceId: string, timestamp = Math.floor(Date.now() / 1000)) { +function makeComputedSessionId( + sourceId: string, + timestamp = Math.floor(Date.now() / 1000), + distinctId?: string, +) { const createdAt = new Date(timestamp * 1000); const sessionSalt = getSalt(process.env.SALT_ROTATION || 'month', createdAt); - return uuid(sourceId, defaultClientInfo.ip, defaultClientInfo.userAgent, sessionSalt); + return uuid(sourceId, defaultClientInfo.ip, defaultClientInfo.userAgent, sessionSalt, distinctId ?? ''); } beforeEach(() => { @@ -755,6 +759,49 @@ describe('30-minute visit expiry', () => { }); describe('identify collection', () => { + test('uses distinct IDs to separate sessions with the same client fingerprint', async () => { + const first = await callPOST({ + type: 'identify', + payload: { website: WEBSITE_ID, id: 'user-1' }, + }); + const second = await callPOST({ + type: 'identify', + payload: { website: WEBSITE_ID, id: 'user-2' }, + }); + + const firstBody = (await first.json()) as Record; + const secondBody = (await second.json()) as Record; + + expect(firstBody.sessionId).not.toBe(secondBody.sessionId); + expect(createSessionMock.mock.calls[0][0]).toMatchObject({ + id: firstBody.sessionId, + distinctId: 'user-1', + }); + expect(createSessionMock.mock.calls[1][0]).toMatchObject({ + id: secondBody.sessionId, + distinctId: 'user-2', + }); + }); + + test('normalizes distinct IDs before calculating the session ID', async () => { + const prefix = 'a'.repeat(50); + const first = await callPOST({ + type: 'identify', + payload: { website: WEBSITE_ID, id: `${prefix}-first` }, + }); + const second = await callPOST({ + type: 'identify', + payload: { website: WEBSITE_ID, id: `${prefix}-second` }, + }); + + const firstBody = (await first.json()) as Record; + const secondBody = (await second.json()) as Record; + + expect(firstBody.sessionId).toBe(secondBody.sessionId); + expect(createSessionMock.mock.calls[0][0]).toMatchObject({ distinctId: prefix }); + expect(createSessionMock.mock.calls[1][0]).toMatchObject({ distinctId: prefix }); + }); + test('saves a session link and updates the session for a new distinctId', async () => { await callPOST({ type: 'identify', diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index a89c25970..a82ffffa4 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -2,9 +2,10 @@ import { startOfHour } from 'date-fns'; import { isbot } from 'isbot'; import { z } from 'zod'; import clickhouse from '@/lib/clickhouse'; -import { CACHE_TOKEN_TYPE, COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants'; +import { CACHE_TOKEN_TYPE, COLLECTION_TYPE, EVENT_TYPE, FIELD_LENGTH } from '@/lib/constants'; import { getSalt, hash, secret, uuid } from '@/lib/crypto'; import { getClientInfo, hasBlockedIp } from '@/lib/detect'; +import { truncateString } from '@/lib/format'; import { createToken, parseToken } from '@/lib/jwt'; import { fetchWebsite } from '@/lib/load'; import { parseRequest } from '@/lib/request'; @@ -150,12 +151,15 @@ export async function POST(request: Request) { const createdAt = timestamp ? new Date(timestamp * 1000) : new Date(); const now = Math.floor(Date.now() / 1000); + const distinctId = truncateString(id, FIELD_LENGTH.distinctId); const saltRotation = process.env.SALT_ROTATION || 'month'; const sessionSalt = getSalt(saltRotation, createdAt); const visitSalt = hash(startOfHour(createdAt).toUTCString()); - const sessionId = uuid(sourceId, ip, userAgent, sessionSalt); + // Identified users need a separate deterministic session from anonymous users + // who happen to share the same IP address and user agent. + const sessionId = uuid(sourceId, ip, userAgent, sessionSalt, distinctId ?? ''); const sessionDrift = !!websiteId && !!cache?.sessionId && cache.sessionId !== sessionId; const shouldEnsureSession = !clickhouse.enabled && sessionDrift; @@ -172,7 +176,7 @@ export async function POST(request: Request) { country, region, city, - distinctId: id, + distinctId, createdAt, }); } @@ -275,7 +279,7 @@ export async function POST(request: Request) { referrerDomain, // Session - distinctId: id, + distinctId, browser, os, device, @@ -306,8 +310,8 @@ export async function POST(request: Request) { twclid, }); } else if (type === COLLECTION_TYPE.identify) { - if (websiteId && id) { - const newLinkId = hash(sessionId, id); + if (websiteId && distinctId) { + const newLinkId = hash(sessionId, distinctId); if (sessionLinkId !== newLinkId) { // Best-effort: identity link failures must not block the session data write below. @@ -316,13 +320,13 @@ export async function POST(request: Request) { saveSessionLink({ websiteId, sessionId, - distinctId: id, + distinctId, createdAt, }), updateSession({ websiteId, sessionId, - distinctId: id, + distinctId, }), ]); sessionLinkId = newLinkId; @@ -338,7 +342,7 @@ export async function POST(request: Request) { websiteId, sessionId, sessionData: data, - distinctId: id, + distinctId, createdAt, }); } diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.test.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.test.ts index 5563590af..bf4228490 100644 --- a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.test.ts +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.test.ts @@ -2,7 +2,7 @@ import { endOfMonth, startOfMonth } from 'date-fns'; import { beforeEach, expect, test, vi } from 'vitest'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { canViewWebsiteSection } from '@/permissions'; -import { getLinkedSessionIds, getSessionActivity } from '@/queries/sql'; +import { getLinkedDistinctIds, getLinkedSessionIds, getSessionActivity } from '@/queries/sql'; import { GET } from './route'; vi.mock('@/lib/request', () => ({ @@ -23,6 +23,7 @@ vi.mock('@/queries/sql', () => ({ const parseRequestMock = vi.mocked(parseRequest); const getQueryFiltersMock = vi.mocked(getQueryFilters); const canViewWebsiteSectionMock = vi.mocked(canViewWebsiteSection); +const getLinkedDistinctIdsMock = vi.mocked(getLinkedDistinctIds); const getLinkedSessionIdsMock = vi.mocked(getLinkedSessionIds); const getSessionActivityMock = vi.mocked(getSessionActivity); @@ -30,6 +31,7 @@ beforeEach(() => { parseRequestMock.mockReset(); getQueryFiltersMock.mockReset(); canViewWebsiteSectionMock.mockReset(); + getLinkedDistinctIdsMock.mockReset(); getLinkedSessionIdsMock.mockReset(); getSessionActivityMock.mockReset(); }); @@ -82,3 +84,29 @@ test('uses linked session months to widen stitched activity without scanning eve ); await expect(response.json()).resolves.toEqual([{ eventId: 'event-1' }]); }); + +test('keeps activity scoped to the raw session when multiple identities are linked', async () => { + const query = { + startAt: +new Date('2026-07-20T23:38:54.000Z'), + endAt: +new Date('2026-07-21T00:08:01.000Z'), + }; + const filters = { startDate: new Date(query.startAt), endDate: new Date(query.endAt) }; + + parseRequestMock.mockResolvedValue({ auth: {}, query, error: undefined }); + canViewWebsiteSectionMock.mockResolvedValue(true); + getLinkedDistinctIdsMock.mockResolvedValue(['distinct-1', 'distinct-2']); + getQueryFiltersMock.mockResolvedValue(filters); + getSessionActivityMock.mockResolvedValue([{ eventId: 'event-1' }]); + + await GET( + new Request( + 'http://localhost/api/websites/website-1/sessions/session-1/activity?startAt=1784590734000&endAt=1784592481000', + ), + { + params: Promise.resolve({ websiteId: 'website-1', sessionId: 'session-1' }), + }, + ); + + expect(getLinkedSessionIdsMock).not.toHaveBeenCalled(); + expect(getSessionActivityMock).toHaveBeenCalledWith('website-1', ['session-1'], filters); +}); diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts index cad956b24..a509adc7b 100644 --- a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts @@ -38,13 +38,11 @@ export async function GET( ? [query.distinctId] : await getLinkedDistinctIds(websiteId, sessionId); - if (distinctIds.length) { - const links = await Promise.all( - distinctIds.map(distinctId => getLinkedSessionIds(websiteId, distinctId)), - ); - const linkedIds = links.flatMap(group => group.map(link => link.sessionId)); + if (distinctIds.length === 1) { + const links = await getLinkedSessionIds(websiteId, distinctIds[0]); + const linkedIds = links.map(link => link.sessionId); const linkedDates = links - .flatMap(group => group.map(link => +new Date(link.createdAt))) + .map(link => +new Date(link.createdAt)) .filter(timestamp => !Number.isNaN(timestamp)); sessionIds = Array.from(new Set([sessionId, ...linkedIds])); diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.test.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.test.ts index df9d2dc61..9cd4b1809 100644 --- a/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.test.ts +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.test.ts @@ -80,6 +80,7 @@ test('GET includes canDelete when relational storage and delete permission are a id: 'session-1', distinctId: 'distinct-1', }); + getLinkedDistinctIdsMock.mockResolvedValue(['distinct-1']); getLinkedSessionIdsMock.mockResolvedValue([ { sessionId: 'session-2', createdAt: '2026-07-24T00:00:00.000Z' }, ]); @@ -95,10 +96,36 @@ test('GET includes canDelete when relational storage and delete permission are a await expect(response.json()).resolves.toMatchObject({ id: 'session-1', canDelete: true, + distinctIds: ['distinct-1'], stitchedSessionCount: 2, }); }); +test('GET does not stitch a session with multiple linked identities', async () => { + parseRequestMock.mockResolvedValue({ auth: {}, error: undefined }); + canViewWebsiteSectionMock.mockResolvedValue(true); + isRelationalOnlyMock.mockReturnValue(true); + canDeleteWebsiteMock.mockResolvedValue(false); + getWebsiteSessionMock.mockResolvedValue({ id: 'session-1', distinctId: 'distinct-2' }); + getLinkedDistinctIdsMock.mockResolvedValue(['distinct-1', 'distinct-2']); + + const response = await GET( + new Request('http://localhost/api/websites/website-1/sessions/session-1'), + { + params: Promise.resolve({ websiteId: 'website-1', sessionId: 'session-1' }), + }, + ); + + const body = await response.json(); + + expect(body).toMatchObject({ + distinctIds: ['distinct-1', 'distinct-2'], + stitchedSessionCount: 1, + }); + expect(body).not.toHaveProperty('distinctId'); + expect(getLinkedSessionIdsMock).not.toHaveBeenCalled(); +}); + test('DELETE rejects session deletion for non-relational storage', async () => { parseRequestMock.mockResolvedValue({ auth: {}, error: undefined }); isRelationalOnlyMock.mockReturnValue(false); diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts index 68aa40c72..5f4824681 100644 --- a/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts @@ -31,19 +31,20 @@ export async function GET( } let sessionIds = [sessionId]; - const distinctIds = data.distinctId - ? [data.distinctId] - : await getLinkedDistinctIds(websiteId, sessionId); + const linkedDistinctIds = await getLinkedDistinctIds(websiteId, sessionId); + const distinctIds = linkedDistinctIds.length + ? linkedDistinctIds + : data.distinctId + ? [data.distinctId] + : []; + const distinctId = distinctIds.length === 1 ? distinctIds[0] : undefined; - if (!data.distinctId && distinctIds.length === 1) { - data.distinctId = distinctIds[0]; - } + // A collided legacy session cannot be safely attributed to one identity. + data.distinctId = distinctId; - if (distinctIds.length) { - const links = await Promise.all( - distinctIds.map(distinctId => getLinkedSessionIds(websiteId, distinctId)), - ); - const linkedIds = links.flatMap(group => group.map(link => link.sessionId)); + if (distinctId) { + const links = await getLinkedSessionIds(websiteId, distinctId); + const linkedIds = links.map(link => link.sessionId); sessionIds = Array.from(new Set([sessionId, ...linkedIds])); } @@ -53,6 +54,7 @@ export async function GET( return json({ ...data, canDelete, + distinctIds, stitchedSessionCount, }); } diff --git a/src/queries/sql/sessions/updateSession.ts b/src/queries/sql/sessions/updateSession.ts index 6780123d1..8a667919a 100644 --- a/src/queries/sql/sessions/updateSession.ts +++ b/src/queries/sql/sessions/updateSession.ts @@ -28,7 +28,7 @@ async function relationalQuery({ websiteId, sessionId, distinctId }: UpdateSessi set distinct_id = {{distinctId}} where website_id = {{websiteId}} and session_id = {{sessionId}} - and coalesce(distinct_id, '') != {{distinctId}} + and coalesce(distinct_id, '') = '' `, { websiteId,