Files
umami/src/queries/sql/heatmap/getHeatmap.ts
T

948 lines
25 KiB
TypeScript

import clickhouse from '@/lib/clickhouse';
import { HEATMAP_EVENT_TYPE, OPERATORS } from '@/lib/constants';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import { filtersObjectToArray } from '@/lib/params';
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
import { getWebsite } from '@/queries/prisma';
const FUNCTION_NAME = 'getHeatmap';
const POINT_LIMIT = 5000;
const PAGE_LIMIT = 100;
const SCROLL_BUCKET_SIZE = 10;
export type HeatmapMode = 'click' | 'scroll';
export interface HeatmapParameters extends QueryFilters {
urlPath?: string;
mode?: HeatmapMode;
}
export interface HeatmapPage {
urlPath: string;
count: number;
sessions: number;
}
export interface HeatmapPoint {
x: number;
y: number;
pageX: number;
pageY: number;
pageW: number;
pageH: number;
viewportW: number;
viewportH: number;
count: number;
}
export interface HeatmapScrollBucket {
depth: number;
sessions: number;
pageW: number;
pageH: number;
viewportW: number;
viewportH: number;
}
export interface HeatmapSnapshotIframe {
kind: 'iframe';
id: string;
url: string;
pageW: number;
pageH: number;
viewportW: number;
viewportH: number;
}
export type HeatmapSnapshot = HeatmapSnapshotIframe;
export interface HeatmapResult {
mode: HeatmapMode;
pages: HeatmapPage[];
points: HeatmapPoint[];
snapshot: HeatmapSnapshot | null;
scroll: {
buckets: HeatmapScrollBucket[];
totalSessions: number;
pageW: number | null;
pageH: number | null;
viewportW: number | null;
viewportH: number | null;
};
}
export async function getHeatmap(
websiteId: string,
parameters: HeatmapParameters,
): Promise<HeatmapResult> {
return runQuery({
[PRISMA]: () => relationalQuery(websiteId, parameters),
[CLICKHOUSE]: () => clickhouseQuery(websiteId, parameters),
});
}
interface HeatmapFilterContext {
joinQuery: string;
filterQuery: string;
queryParams: Record<string, any>;
}
async function relationalQuery(
websiteId: string,
parameters: HeatmapParameters,
): Promise<HeatmapResult> {
const { rawQuery } = prisma;
const { startDate, endDate, urlPath, mode = 'click' } = parameters;
const eventType = mode === 'scroll' ? HEATMAP_EVENT_TYPE.scroll : HEATMAP_EVENT_TYPE.click;
const filterContext = getRelationalHeatmapFilterContext(websiteId, parameters);
const pageFilter =
mode === 'click'
? `
and x is not null
and y is not null
and page_x is not null
and page_y is not null
and page_w is not null
and page_h is not null
and viewport_w is not null
and viewport_h is not null
`
: `
and scroll_pct is not null
and page_w is not null
and page_h is not null
and viewport_w is not null
and viewport_h is not null
`;
const rawPages: HeatmapPage[] = await rawQuery(
`
select
h.url_path as "urlPath",
count(*)::int as count,
count(distinct h.visit_id)::int as sessions
from heatmap_event h
${filterContext.joinQuery}
where h.website_id = {{websiteId::uuid}}
and h.event_type = {{eventType}}
and h.created_at between {{startDate}} and {{endDate}}
${filterContext.filterQuery}
${pageFilter}
group by h.url_path
order by sessions desc, count desc
limit ${PAGE_LIMIT}
`,
{ ...filterContext.queryParams, websiteId, eventType, startDate, endDate },
FUNCTION_NAME,
);
const pages = rawPages;
if (!urlPath) {
return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() };
}
if (mode === 'scroll') {
const bucketRows: {
depth: number | string;
sessions: number | string;
pageW: number | string;
pageH: number | string;
viewportW: number | string;
viewportH: number | string;
}[] = await rawQuery(
`
select
(floor(max_pct / ${SCROLL_BUCKET_SIZE}) * ${SCROLL_BUCKET_SIZE})::int as depth,
count(*)::int as sessions,
page_w::int as "pageW",
page_h::int as "pageH",
viewport_w::int as "viewportW",
viewport_h::int as "viewportH"
from (
select
h.visit_id,
max(h.scroll_pct) as max_pct,
(mode() within group (order by h.page_w))::int as page_w,
(mode() within group (order by h.page_h))::int as page_h,
(mode() within group (order by h.viewport_w))::int as viewport_w,
(mode() within group (order by h.viewport_h))::int as viewport_h
from heatmap_event h
${filterContext.joinQuery}
where h.website_id = {{websiteId::uuid}}
and h.event_type = {{eventType}}
and h.url_path = {{urlPath}}
and h.created_at between {{startDate}} and {{endDate}}
${filterContext.filterQuery}
and h.scroll_pct is not null
and h.page_w is not null
and h.page_h is not null
and h.viewport_w is not null
and h.viewport_h is not null
group by h.visit_id
) per_session
group by depth, page_w, page_h, viewport_w, viewport_h
order by depth
`,
{ ...filterContext.queryParams, websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const scrollBuckets = bucketRows.map(r => ({
depth: Number(r.depth),
sessions: Number(r.sessions),
pageW: Number(r.pageW),
pageH: Number(r.pageH),
viewportW: Number(r.viewportW),
viewportH: Number(r.viewportH),
}));
const viewport = pickScrollSnapshotViewport(scrollBuckets);
const scroll = {
buckets: scrollBuckets,
totalSessions: scrollBuckets.reduce((sum, bucket) => sum + bucket.sessions, 0),
pageW: viewport?.pageW ?? null,
pageH: viewport?.pageH ?? null,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
};
const snapshot = await resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
pageW: viewport?.pageW ?? null,
pageH: viewport?.pageH ?? null,
});
return {
mode,
pages,
points: [],
snapshot,
scroll,
};
}
const rawPoints: HeatmapPoint[] = await rawQuery(
`
select
h.x,
h.y,
h.page_x as "pageX",
h.page_y as "pageY",
h.page_w as "pageW",
h.page_h as "pageH",
h.viewport_w as "viewportW",
h.viewport_h as "viewportH",
count(*)::int as count
from heatmap_event h
${filterContext.joinQuery}
where h.website_id = {{websiteId::uuid}}
and h.event_type = {{eventType}}
and h.url_path = {{urlPath}}
and h.created_at between {{startDate}} and {{endDate}}
${filterContext.filterQuery}
and h.x is not null
and h.y is not null
and h.page_x is not null
and h.page_y is not null
and h.page_w is not null
and h.page_h is not null
and h.viewport_w is not null
and h.viewport_h is not null
group by
h.x,
h.y,
h.page_x,
h.page_y,
h.page_w,
h.page_h,
h.viewport_w,
h.viewport_h
order by count desc
limit ${POINT_LIMIT}
`,
{ ...filterContext.queryParams, websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const viewport = pickSnapshotViewport(rawPoints);
const snapshot = await resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
pageW: viewport?.pageW ?? null,
pageH: viewport?.pageH ?? null,
});
return { mode, pages, points: rawPoints, snapshot, scroll: emptyScroll() };
}
async function clickhouseQuery(
websiteId: string,
parameters: HeatmapParameters,
): Promise<HeatmapResult> {
const { rawQuery } = clickhouse;
const { startDate, endDate, urlPath, mode = 'click' } = parameters;
const eventType = mode === 'scroll' ? HEATMAP_EVENT_TYPE.scroll : HEATMAP_EVENT_TYPE.click;
const filterContext = getClickhouseHeatmapFilterContext(websiteId, parameters);
const pageFilter =
mode === 'click'
? `
and x is not null
and y is not null
and page_x is not null
and page_y is not null
and page_w is not null
and page_h is not null
and viewport_w is not null
and viewport_h is not null
`
: `
and scroll_pct is not null
and page_w is not null
and page_h is not null
and viewport_w is not null
and viewport_h is not null
`;
const pageRows = await rawQuery<
{ urlPath: string; count: string | number; sessions: string | number }[]
>(
`
select
h.url_path as urlPath,
count() as count,
uniq(h.visit_id) as sessions
from heatmap_event h
${filterContext.joinQuery}
where h.website_id = {websiteId:UUID}
and h.event_type = {eventType:UInt8}
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterContext.filterQuery}
${pageFilter}
group by h.url_path
order by sessions desc, count desc
limit ${PAGE_LIMIT}
`,
{ ...filterContext.queryParams, websiteId, eventType, startDate, endDate },
FUNCTION_NAME,
);
const pages: HeatmapPage[] = pageRows
.map(p => ({
urlPath: p.urlPath,
count: Number(p.count),
sessions: Number(p.sessions),
}));
if (!urlPath) {
return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() };
}
if (mode === 'scroll') {
const bucketRows = await rawQuery<
{
depth: number | string;
sessions: number | string;
pageW: number | string;
pageH: number | string;
viewportW: number | string;
viewportH: number | string;
}[]
>(
`
select
intDiv(max_pct, ${SCROLL_BUCKET_SIZE}) * ${SCROLL_BUCKET_SIZE} as depth,
count() as sessions,
pageW,
pageH,
viewportW,
viewportH
from (
select
h.visit_id,
max(h.scroll_pct) as max_pct,
toInt32OrNull(toString(arrayElement(topK(1)(h.page_w), 1))) as pageW,
toInt32OrNull(toString(arrayElement(topK(1)(h.page_h), 1))) as pageH,
toInt32OrNull(toString(arrayElement(topK(1)(h.viewport_w), 1))) as viewportW,
toInt32OrNull(toString(arrayElement(topK(1)(h.viewport_h), 1))) as viewportH
from heatmap_event h
${filterContext.joinQuery}
where h.website_id = {websiteId:UUID}
and h.event_type = {eventType:UInt8}
and h.url_path = {urlPath:String}
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterContext.filterQuery}
and h.scroll_pct is not null
and h.page_w is not null
and h.page_h is not null
and h.viewport_w is not null
and h.viewport_h is not null
group by h.visit_id
)
where pageW is not null
and pageH is not null
and viewportW is not null
and viewportH is not null
group by depth, pageW, pageH, viewportW, viewportH
order by depth
`,
{ ...filterContext.queryParams, websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const scrollBuckets = bucketRows.map(r => ({
depth: Number(r.depth),
sessions: Number(r.sessions),
pageW: Number(r.pageW),
pageH: Number(r.pageH),
viewportW: Number(r.viewportW),
viewportH: Number(r.viewportH),
}));
const viewport = pickScrollSnapshotViewport(scrollBuckets);
const scroll = {
buckets: scrollBuckets,
totalSessions: scrollBuckets.reduce((sum, bucket) => sum + bucket.sessions, 0),
pageW: viewport?.pageW ?? null,
pageH: viewport?.pageH ?? null,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
};
const snapshot = await resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
pageW: viewport?.pageW ?? null,
pageH: viewport?.pageH ?? null,
});
return {
mode,
pages,
points: [],
snapshot,
scroll,
};
}
const pointRows = await rawQuery<
{
x: number;
y: number;
pageX: number;
pageY: number;
pageW: number;
pageH: number;
viewportW: number;
viewportH: number;
count: string | number;
}[]
>(
`
select
h.x,
h.y,
h.page_x as pageX,
h.page_y as pageY,
h.page_w as pageW,
h.page_h as pageH,
h.viewport_w as viewportW,
h.viewport_h as viewportH,
count() as count
from heatmap_event h
${filterContext.joinQuery}
where h.website_id = {websiteId:UUID}
and h.event_type = {eventType:UInt8}
and h.url_path = {urlPath:String}
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterContext.filterQuery}
and h.x is not null
and h.y is not null
and h.page_x is not null
and h.page_y is not null
and h.page_w is not null
and h.page_h is not null
and h.viewport_w is not null
and h.viewport_h is not null
group by
h.x,
h.y,
h.page_x,
h.page_y,
h.page_w,
h.page_h,
h.viewport_w,
h.viewport_h
order by count desc
limit ${POINT_LIMIT}
`,
{ ...filterContext.queryParams, websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const points: HeatmapPoint[] = pointRows.map(p => ({
x: Number(p.x),
y: Number(p.y),
pageX: Number(p.pageX),
pageY: Number(p.pageY),
pageW: Number(p.pageW),
pageH: Number(p.pageH),
viewportW: Number(p.viewportW),
viewportH: Number(p.viewportH),
count: Number(p.count),
}));
const viewport = pickSnapshotViewport(points);
const snapshot = await resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
pageW: viewport?.pageW ?? null,
pageH: viewport?.pageH ?? null,
});
return { mode, pages, points, snapshot, scroll: emptyScroll() };
}
function emptyScroll(): HeatmapResult['scroll'] {
return {
buckets: [],
totalSessions: 0,
pageW: null,
pageH: null,
viewportW: null,
viewportH: null,
};
}
async function resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW,
viewportH,
pageW,
pageH,
}: {
websiteId: string;
urlPath: string;
viewportW: number | null;
viewportH: number | null;
pageW: number | null;
pageH: number | null;
}): Promise<HeatmapSnapshot | null> {
return getIframeSnapshot({
websiteId,
urlPath,
viewportW,
viewportH,
pageW,
pageH,
});
}
async function getIframeSnapshot({
websiteId,
urlPath,
viewportW,
viewportH,
pageW,
pageH,
}: {
websiteId: string;
urlPath: string;
viewportW: number | null;
viewportH: number | null;
pageW: number | null;
pageH: number | null;
}): Promise<HeatmapSnapshotIframe | null> {
if (!urlPath || !viewportW || !pageW || !pageH) {
return null;
}
const website = await getWebsite(websiteId);
const url = buildHeatmapPageUrl(website?.domain, urlPath);
if (!url) {
return null;
}
const fallbackViewportH = Math.min(Math.max(pageH, 640), 1080);
const height = viewportH || fallbackViewportH;
return {
kind: 'iframe',
id: `iframe:${websiteId}:${urlPath}:${viewportW}x${height}`,
url,
pageW,
pageH,
viewportW,
viewportH: height,
};
}
function getFirstDomain(domain?: string | null) {
return domain?.split(',')[0]?.trim() || null;
}
function getWebsiteOrigin(domain?: string | null) {
const host = getFirstDomain(domain);
if (!host) {
return null;
}
if (host.startsWith('http://') || host.startsWith('https://')) {
return new URL(host);
}
const protocol =
host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]')
? 'http'
: 'https';
return new URL(`${protocol}://${host}`);
}
function buildHeatmapPageUrl(domain: string | null | undefined, urlPath: string) {
try {
const origin = getWebsiteOrigin(domain);
if (!origin) {
return null;
}
return new URL(urlPath || '/', origin).toString();
} catch {
return null;
}
}
function pickSnapshotViewport(
points: HeatmapPoint[],
): { width: number; height: number; pageW: number; pageH: number } | null {
const viewportBuckets = new Map<
string,
{
width: number;
height: number;
count: number;
maxPageW: number;
maxPageH: number;
}
>();
for (const p of points) {
const viewportKey = `${p.viewportW}x${p.viewportH}`;
const viewportBucket = viewportBuckets.get(viewportKey);
if (viewportBucket) {
viewportBucket.count += p.count;
viewportBucket.maxPageW = Math.max(viewportBucket.maxPageW, p.pageW);
viewportBucket.maxPageH = Math.max(viewportBucket.maxPageH, p.pageH);
} else {
viewportBuckets.set(viewportKey, {
width: p.viewportW,
height: p.viewportH,
count: p.count,
maxPageW: p.pageW,
maxPageH: p.pageH,
});
}
}
let bestViewport: {
width: number;
height: number;
count: number;
maxPageW: number;
maxPageH: number;
} | null = null;
for (const bucket of viewportBuckets.values()) {
if (!bestViewport || bucket.count > bestViewport.count) {
bestViewport = bucket;
}
}
if (!bestViewport) {
return null;
}
return {
width: bestViewport.width,
height: bestViewport.height,
pageW: bestViewport.maxPageW,
pageH: bestViewport.maxPageH,
};
}
function pickScrollSnapshotViewport(
buckets: HeatmapScrollBucket[],
): { width: number; height: number; pageW: number; pageH: number } | null {
const viewportBuckets = new Map<
string,
{
width: number;
height: number;
sessions: number;
maxPageW: number;
maxPageH: number;
}
>();
for (const bucket of buckets) {
const viewportKey = `${bucket.viewportW}x${bucket.viewportH}`;
const viewportBucket = viewportBuckets.get(viewportKey);
if (viewportBucket) {
viewportBucket.sessions += bucket.sessions;
viewportBucket.maxPageW = Math.max(viewportBucket.maxPageW, bucket.pageW);
viewportBucket.maxPageH = Math.max(viewportBucket.maxPageH, bucket.pageH);
} else {
viewportBuckets.set(viewportKey, {
width: bucket.viewportW,
height: bucket.viewportH,
sessions: bucket.sessions,
maxPageW: bucket.pageW,
maxPageH: bucket.pageH,
});
}
}
let bestViewport: {
width: number;
height: number;
sessions: number;
maxPageW: number;
maxPageH: number;
} | null = null;
for (const bucket of viewportBuckets.values()) {
if (!bestViewport || bucket.sessions > bestViewport.sessions) {
bestViewport = bucket;
}
}
if (!bestViewport) {
return null;
}
return {
width: bestViewport.width,
height: bestViewport.height,
pageW: bestViewport.maxPageW,
pageH: bestViewport.maxPageH,
};
}
function getHeatmapPathFilters(filters: QueryFilters) {
return filtersObjectToArray(filters).filter(filter => filter.name === 'path');
}
function omitHeatmapPathFilters(filters: QueryFilters): QueryFilters {
return Object.fromEntries(
Object.entries(filters).filter(([key]) => key.replace(/\d+$/, '') !== 'path'),
) as QueryFilters;
}
function getRelationalHeatmapPathFilterContext(filters: QueryFilters) {
const pathFilters = getHeatmapPathFilters(filters);
if (!pathFilters.length) {
return { filterQuery: '', queryParams: {} };
}
const clauses = pathFilters.map(({ operator, paramName, name }) => {
const key = paramName ?? name;
switch (operator) {
case OPERATORS.equals:
return `h.url_path = ANY({{${key}}})`;
case OPERATORS.notEquals:
return `h.url_path != ALL({{${key}}})`;
case OPERATORS.contains:
return `h.url_path ilike {{${key}}}`;
case OPERATORS.doesNotContain:
return `h.url_path not ilike {{${key}}}`;
case OPERATORS.regex:
return `h.url_path ~* {{${key}}}`;
case OPERATORS.notRegex:
return `h.url_path !~* {{${key}}}`;
default:
return '';
}
});
const queryParams = Object.fromEntries(
pathFilters.map(({ operator, value, paramName, name }) => {
const key = paramName ?? name;
if (operator === OPERATORS.contains || operator === OPERATORS.doesNotContain) {
return [key, `%${value}%`];
}
if (operator === OPERATORS.equals || operator === OPERATORS.notEquals) {
return [key, Array.isArray(value) ? value : [value]];
}
return [key, Array.isArray(value) ? value[0] : value];
}),
);
const joinedClauses =
filters.match === 'any'
? clauses.filter(Boolean).join('\n or ')
: clauses.filter(Boolean).join('\n and ');
return {
filterQuery:
filters.match === 'any'
? `and (\n ${joinedClauses}\n )`
: clauses.length
? `and ${joinedClauses}`
: '',
queryParams,
};
}
function getClickhouseHeatmapPathFilterContext(filters: QueryFilters) {
const pathFilters = getHeatmapPathFilters(filters);
if (!pathFilters.length) {
return { filterQuery: '', queryParams: {} };
}
const clauses = pathFilters.map(({ operator, paramName, name }) => {
const key = paramName ?? name;
switch (operator) {
case OPERATORS.equals:
return `h.url_path IN {${key}:Array(String)}`;
case OPERATORS.notEquals:
return `h.url_path NOT IN {${key}:Array(String)}`;
case OPERATORS.contains:
return `positionCaseInsensitive(h.url_path, {${key}:String}) > 0`;
case OPERATORS.doesNotContain:
return `positionCaseInsensitive(h.url_path, {${key}:String}) = 0`;
case OPERATORS.regex:
return `match(h.url_path, concat('(?i)', {${key}:String}))`;
case OPERATORS.notRegex:
return `not match(h.url_path, concat('(?i)', {${key}:String}))`;
default:
return '';
}
});
const queryParams = Object.fromEntries(
pathFilters.map(({ operator, value, paramName, name }) => {
const key = paramName ?? name;
if (operator === OPERATORS.equals || operator === OPERATORS.notEquals) {
return [key, Array.isArray(value) ? value : [value]];
}
return [key, Array.isArray(value) ? value[0] : value];
}),
);
const joinedClauses =
filters.match === 'any'
? clauses.filter(Boolean).join('\n or ')
: clauses.filter(Boolean).join('\n and ');
return {
filterQuery:
filters.match === 'any'
? `and (\n ${joinedClauses}\n )`
: clauses.length
? `and ${joinedClauses}`
: '',
queryParams,
};
}
function getRelationalHeatmapFilterContext(
websiteId: string,
filters: QueryFilters,
): HeatmapFilterContext {
const { parseFilters } = prisma;
const pathFilterContext = getRelationalHeatmapPathFilterContext(filters);
const { filterQuery, cohortQuery, excludeBounceQuery, joinSessionQuery, queryParams } =
parseFilters({
...omitHeatmapPathFilters(filters),
websiteId,
});
if (!(filterQuery || cohortQuery || excludeBounceQuery)) {
return {
joinQuery: '',
filterQuery: pathFilterContext.filterQuery,
queryParams: pathFilterContext.queryParams,
};
}
return {
joinQuery: `
inner join (
select distinct website_event.website_id, website_event.session_id, website_event.visit_id
from website_event
${joinSessionQuery}
${cohortQuery}
${excludeBounceQuery}
where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
${filterQuery}
) filtered_visits
on filtered_visits.website_id = h.website_id
and filtered_visits.session_id = h.session_id
and filtered_visits.visit_id = h.visit_id
`,
filterQuery: pathFilterContext.filterQuery,
queryParams: { ...queryParams, ...pathFilterContext.queryParams },
};
}
function getClickhouseHeatmapFilterContext(
websiteId: string,
filters: QueryFilters,
): HeatmapFilterContext {
const { parseFilters } = clickhouse;
const pathFilterContext = getClickhouseHeatmapPathFilterContext(filters);
const { filterQuery, cohortQuery, excludeBounceQuery, queryParams } = parseFilters({
...omitHeatmapPathFilters(filters),
websiteId,
});
if (!(filterQuery || cohortQuery || excludeBounceQuery)) {
return {
joinQuery: '',
filterQuery: pathFilterContext.filterQuery,
queryParams: pathFilterContext.queryParams,
};
}
return {
joinQuery: `
inner join (
select distinct website_id, session_id, visit_id
from website_event
${cohortQuery}
${excludeBounceQuery}
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
${filterQuery}
) filtered_visits
on filtered_visits.website_id = h.website_id
and filtered_visits.session_id = h.session_id
and filtered_visits.visit_id = h.visit_id
`,
filterQuery: pathFilterContext.filterQuery,
queryParams: { ...queryParams, ...pathFilterContext.queryParams },
};
}