fix: separate identified sessions by distinct id

This commit is contained in:
Francis Cao committed 2026-09-15 11:46:08 -07:00
1 parent db26ff1b98
commit 188b628cf0
9 files changed
+141 -35

No files matched your search

-3
View File
@@ -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 },
}}
/>
@@ -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 }) {
</Info>
{distinctId && stitchedSessionCount > 1 && (
<Info label="Linked IDs" icon={<Network />}>
<Info label="Linked sessions" icon={<Network />}>
{stitchedSessionCount}
</Info>
)}
+49 -2
View File
@@ -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<string, any>;
const secondBody = (await second.json()) as Record<string, any>;
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<string, any>;
const secondBody = (await second.json()) as Record<string, any>;
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',
+13 -9
View File
@@ -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,
});
}
@@ -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);
});
@@ -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]));
@@ -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);
@@ -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,
});
}
+1 -1
View File
@@ -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,