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,