fix: separate identified sessions by distinct id
This commit is contained in:
1 parent
db26ff1b98
commit
188b628cf0
9 files changed
+141
-35
No files matched your search
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user