finish clickhouse journeys query

This commit is contained in:
Francis Cao 2024-06-03 23:40:38 -07:00
parent 3a6971e173
commit 0333bec986
8 changed files with 151 additions and 42 deletions

View File

@ -1,11 +1,12 @@
'use client'; 'use client';
import FunnelReport from '../funnel/FunnelReport'; import { useReport } from 'components/hooks';
import EventDataReport from '../event-data/EventDataReport'; import EventDataReport from '../event-data/EventDataReport';
import FunnelReport from '../funnel/FunnelReport';
import GoalReport from '../goals/GoalsReport';
import InsightsReport from '../insights/InsightsReport'; import InsightsReport from '../insights/InsightsReport';
import JourneyReport from '../journey/JourneyReport';
import RetentionReport from '../retention/RetentionReport'; import RetentionReport from '../retention/RetentionReport';
import UTMReport from '../utm/UTMReport'; import UTMReport from '../utm/UTMReport';
import GoalReport from '../goals/GoalsReport';
import { useReport } from 'components/hooks';
const reports = { const reports = {
funnel: FunnelReport, funnel: FunnelReport,
@ -14,6 +15,7 @@ const reports = {
retention: RetentionReport, retention: RetentionReport,
utm: UTMReport, utm: UTMReport,
goals: GoalReport, goals: GoalReport,
journey: JourneyReport,
}; };
export default function ReportPage({ reportId }: { reportId: string }) { export default function ReportPage({ reportId }: { reportId: string }) {

View File

@ -1,6 +1,6 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import { Form, FormButtons, SubmitButton } from 'react-basics'; import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from 'react-basics';
import { ReportContext } from '../[reportId]/Report'; import { ReportContext } from '../[reportId]/Report';
import BaseParameters from '../[reportId]/BaseParameters'; import BaseParameters from '../[reportId]/BaseParameters';
@ -9,8 +9,8 @@ export function JourneyParameters() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {}; const { id, parameters } = report || {};
const { websiteId, dateRange } = parameters || {}; const { websiteId, dateRange, steps } = parameters || {};
const queryDisabled = !websiteId || !dateRange; const queryDisabled = !websiteId || !dateRange || !steps;
const handleSubmit = (data: any, e: any) => { const handleSubmit = (data: any, e: any) => {
e.stopPropagation(); e.stopPropagation();
@ -24,6 +24,24 @@ export function JourneyParameters() {
return ( return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}> <Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} /> <BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
<FormRow label={`${formatMessage(labels.steps)} (3 to 7)`}>
<FormInput
name="steps"
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/, min: 3, max: 7 }}
>
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.startStep)}>
<FormInput name="startStep">
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.endStep)}>
<FormInput name="endStep">
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormButtons> <FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}> <SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)} {formatMessage(labels.runQuery)}

View File

@ -10,7 +10,7 @@ import { REPORT_TYPES } from 'lib/constants';
const defaultParameters = { const defaultParameters = {
type: REPORT_TYPES.journey, type: REPORT_TYPES.journey,
parameters: {}, parameters: { steps: 5 },
}; };
export default function JourneyReport({ reportId }: { reportId?: string }) { export default function JourneyReport({ reportId }: { reportId?: string }) {

View File

@ -8,14 +8,13 @@ import { useEscapeKey } from 'components/hooks';
export default function JourneyView() { export default function JourneyView() {
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const { report } = useContext(ReportContext); const { report } = useContext(ReportContext);
const { data } = report || {}; const { data, parameters } = report || {};
useEscapeKey(() => setSelected(null)); useEscapeKey(() => setSelected(null));
const columns = useMemo(() => { const columns = useMemo(() => {
if (!data) { if (!data) {
return []; return [];
} }
return Array(data[0].items.length) return Array(Number(parameters.steps))
.fill(undefined) .fill(undefined)
.map((col = {}, index) => { .map((col = {}, index) => {
data.forEach(({ items, count }) => { data.forEach(({ items, count }) => {

View File

@ -240,6 +240,8 @@ export const labels = defineMessages({
defaultMessage: 'Track your campaigns through UTM parameters.', defaultMessage: 'Track your campaigns through UTM parameters.',
}, },
steps: { id: 'label.steps', defaultMessage: 'Steps' }, steps: { id: 'label.steps', defaultMessage: 'Steps' },
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
addStep: { id: 'label.add-step', defaultMessage: 'Add step' }, addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
goal: { id: 'label.goal', defaultMessage: 'Goal' }, goal: { id: 'label.goal', defaultMessage: 'Goal' },
goals: { id: 'label.goals', defaultMessage: 'Goals' }, goals: { id: 'label.goals', defaultMessage: 'Goals' },

View File

@ -27,7 +27,7 @@ const schema = {
name: yup.string().max(200).required(), name: yup.string().max(200).required(),
type: yup type: yup
.string() .string()
.matches(/funnel|insights|retention|utm|goals/i) .matches(/funnel|insights|retention|utm|goals|journey/i)
.required(), .required(),
description: yup.string().max(500), description: yup.string().max(500),
parameters: yup parameters: yup

View File

@ -9,6 +9,9 @@ import * as yup from 'yup';
export interface RetentionRequestBody { export interface RetentionRequestBody {
websiteId: string; websiteId: string;
dateRange: { startDate: string; endDate: string }; dateRange: { startDate: string; endDate: string };
steps: number;
startStep?: string;
endStep?: string;
} }
const schema = { const schema = {
@ -21,6 +24,9 @@ const schema = {
endDate: yup.date().required(), endDate: yup.date().required(),
}) })
.required(), .required(),
steps: yup.number().min(3).max(7).required(),
startStep: yup.string(),
endStep: yup.string(),
}), }),
}; };
@ -36,6 +42,9 @@ export default async (
const { const {
websiteId, websiteId,
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
steps,
startStep,
endStep,
} = req.body; } = req.body;
if (!(await canViewWebsite(req.auth, websiteId))) { if (!(await canViewWebsite(req.auth, websiteId))) {
@ -45,6 +54,9 @@ export default async (
const data = await getJourney(websiteId, { const data = await getJourney(websiteId, {
startDate: new Date(startDate), startDate: new Date(startDate),
endDate: new Date(endDate), endDate: new Date(endDate),
steps,
startStep,
endStep,
}); });
return ok(res, data); return ok(res, data);

View File

@ -8,6 +8,8 @@ interface JourneyResult {
e3: string; e3: string;
e4: string; e4: string;
e5: string; e5: string;
e6: string;
e7: string;
count: string; count: string;
} }
@ -17,6 +19,9 @@ export async function getJourney(
filters: { filters: {
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
steps: number;
startStep?: string;
endStep?: string;
}, },
] ]
) { ) {
@ -49,14 +54,14 @@ async function relationalQuery(
and created_at between {{startDate}} and {{endDate}} and created_at between {{startDate}} and {{endDate}}
and referrer_path != url_path), and referrer_path != url_path),
sequences as ( sequences as (
SELECT s.e1, select s.e1,
s.e2, s.e2,
s.e3, s.e3,
s.e4, s.e4,
s.e5, s.e5,
count(*) count count(*) count
FROM ( FROM (
SELECT session_id, select session_id,
MAX(CASE WHEN event_number = 1 THEN event ELSE NULL END) AS e1, MAX(CASE WHEN event_number = 1 THEN event ELSE NULL END) AS e1,
MAX(CASE WHEN event_number = 2 THEN event ELSE NULL END) AS e2, MAX(CASE WHEN event_number = 2 THEN event ELSE NULL END) AS e2,
MAX(CASE WHEN event_number = 3 THEN event ELSE NULL END) AS e3, MAX(CASE WHEN event_number = 3 THEN event ELSE NULL END) AS e3,
@ -87,46 +92,99 @@ async function clickhouseQuery(
filters: { filters: {
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
steps: number;
startStep?: string;
endStep?: string;
}, },
): Promise<JourneyResult[]> { ): Promise<JourneyResult[]> {
const { startDate, endDate } = filters; const { startDate, endDate, steps, startStep, endStep } = filters;
const { rawQuery } = clickhouse; const { rawQuery } = clickhouse;
const { sequenceQuery, startStepQuery, endStepQuery, params } = getJourneyQuery(
steps,
startStep,
endStep,
);
function getJourneyQuery(
steps: number,
startStep?: string,
endStep?: string,
): {
sequenceQuery: string;
startStepQuery: string;
endStepQuery: string;
params: { [key: string]: string };
} {
const params = {};
let sequenceQuery = '';
let startStepQuery = '';
let endStepQuery = '';
// create sequence query
let selectQuery = '';
let maxQuery = '';
let groupByQuery = '';
for (let i = 1; i <= steps; i++) {
const endQuery = i < steps ? ',' : '';
selectQuery += `s.e${i},`;
maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN event ELSE NULL END) AS e${i}${endQuery}`;
groupByQuery += `s.e${i}${endQuery} `;
}
sequenceQuery = `\nsequences as (
select ${selectQuery}
count(*) count
FROM (
select visit_id,
${maxQuery}
FROM events
group by visit_id) s
group by ${groupByQuery})
`;
// create start Step params query
if (startStep) {
startStepQuery = `and e1 = {startStep:String}`;
params['startStep'] = startStep;
}
// create end Step params query
if (endStep) {
for (let i = 1; i < steps; i++) {
const startQuery = i === 1 ? 'and (' : '\nor ';
endStepQuery += `${startQuery}(e${i} = {endStep:String} and e${i + 1} is null) `;
}
endStepQuery += `\nor (e${steps} = {endStep:String}))`;
params['endStep'] = endStep;
}
return {
sequenceQuery,
startStepQuery,
endStepQuery,
params,
};
}
return rawQuery( return rawQuery(
` `
WITH events AS ( WITH events AS (
select distinct select distinct
session_id, visit_id,
referrer_path, referrer_path,
coalesce(nullIf(event_name, ''), url_path) event, coalesce(nullIf(event_name, ''), url_path) event,
row_number() OVER (PARTITION BY session_id ORDER BY created_at) AS event_number row_number() OVER (PARTITION BY visit_id ORDER BY created_at) AS event_number
from umami.website_event from umami.website_event
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}),
and referrer_path != url_path), ${sequenceQuery}
sequences as (
SELECT s.e1,
s.e2,
s.e3,
s.e4,
s.e5,
count(*) count
FROM (
SELECT session_id,
max(CASE WHEN event_number = 1 THEN event ELSE NULL END) AS e1,
max(CASE WHEN event_number = 2 THEN event ELSE NULL END) AS e2,
max(CASE WHEN event_number = 3 THEN event ELSE NULL END) AS e3,
max(CASE WHEN event_number = 4 THEN event ELSE NULL END) AS e4,
max(CASE WHEN event_number = 5 THEN event ELSE NULL END) AS e5
FROM events
group by session_id) s
group by s.e1,
s.e2,
s.e3,
s.e4,
s.e5)
select * select *
from sequences from sequences
where 1 = 1
${startStepQuery}
${endStepQuery}
order by count desc order by count desc
limit 100 limit 100
`, `,
@ -134,10 +192,28 @@ async function clickhouseQuery(
websiteId, websiteId,
startDate, startDate,
endDate, endDate,
...params,
}, },
).then(parseResult); ).then(parseResult);
} }
function parseResult(data: any) { function combineSequentialDuplicates(array: any) {
return data.map(({ e1, e2, e3, e4, e5, count }) => ({ items: [e1, e2, e3, e4, e5], count })); if (array.length === 0) return array;
const result = [array[0]];
for (let i = 1; i < array.length; i++) {
if (array[i] !== array[i - 1]) {
result.push(array[i]);
}
}
return result;
}
function parseResult(data: any) {
return data.map(({ e1, e2, e3, e4, e5, e6, e7, count }) => ({
items: combineSequentialDuplicates([e1, e2, e3, e4, e5, e6, e7]),
count,
}));
} }