add attribution report params

This commit is contained in:
Francis Cao 2025-03-20 09:09:28 -07:00
parent 203e782530
commit 64dcc5af80
5 changed files with 368 additions and 48 deletions

View file

@ -1,69 +1,91 @@
import { useMessages } from '@/components/hooks';
import Icons from '@/components/icons';
import { useContext, useState } from 'react';
import { Dropdown, Form, FormButtons, FormInput, FormRow, Item, SubmitButton } from 'react-basics';
import {
Button,
Dropdown,
Form,
FormButtons,
FormInput,
FormRow,
Icon,
Item,
Popup,
PopupTrigger,
SubmitButton,
} from 'react-basics';
import BaseParameters from '../[reportId]/BaseParameters';
import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm';
import { ReportContext } from '../[reportId]/Report';
import FunnelStepAddForm from '../funnel/FunnelStepAddForm';
import styles from './AttributionParameters.module.css';
import AttributionStepAddForm from './AttributionStepAddForm';
export function AttributionParameters() {
const [model, setModel] = useState('firstClick');
const { report, runReport, isRunning } = useContext(ReportContext);
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const queryEnabled = websiteId && dateRange;
const { websiteId, dateRange, steps } = parameters || {};
const queryEnabled = websiteId && dateRange && steps.length > 0;
const [model, setModel] = useState('');
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
runReport(data);
};
// const handleAddStep = (step: { type: string; value: string }) => {
// updateReport({ parameters: { steps: parameters.steps.concat(step) } });
// };
const handleAddStep = (step: { type: string; value: string }) => {
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
};
// const handleUpdateStep = (
// close: () => void,
// index: number,
// step: { type: string; value: string },
// ) => {
// const steps = [...parameters.steps];
// steps[index] = step;
// updateReport({ parameters: { steps } });
// close();
// };
const handleUpdateStep = (
close: () => void,
index: number,
step: { type: string; value: string },
) => {
const steps = [...parameters.steps];
steps[index] = step;
updateReport({ parameters: { steps } });
close();
};
// const handleRemoveStep = (index: number) => {
// const steps = [...parameters.steps];
// delete steps[index];
// updateReport({ parameters: { steps: steps.filter(n => n) } });
// };
const handleRemoveStep = (index: number) => {
const steps = [...parameters.steps];
delete steps[index];
updateReport({ parameters: { steps: steps.filter(n => n) } });
};
// const AddStepButton = () => {
// return (
// <PopupTrigger>
// <Button>
// <Icon>
// <Icons.Plus />
// </Icon>
// </Button>
// <Popup alignment="start">
// <PopupForm>
// <FunnelStepAddForm onChange={handleAddStep} />
// </PopupForm>
// </Popup>
// </PopupTrigger>
// );
// };
const AddStepButton = () => {
return (
<PopupTrigger disabled={steps.length > 0}>
<Button disabled={steps.length > 0}>
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popup alignment="start">
<PopupForm>
<FunnelStepAddForm onChange={handleAddStep} />
</PopupForm>
</Popup>
</PopupTrigger>
);
};
const attributionModel = [
const items = [
{ label: 'First-Click', value: 'firstClick' },
{ label: 'Last-Click', value: 'lastClick' },
];
const renderModelValue = (value: any) => {
return attributionModel.find(item => item.value === value)?.label;
return items.find(item => item.value === value)?.label;
};
const onModelChange = (value: any) => {
setModel(value);
updateReport({ parameters: { model } });
};
return (
@ -72,10 +94,10 @@ export function AttributionParameters() {
<FormRow label={formatMessage(labels.model)}>
<FormInput name="model" rules={{ required: formatMessage(labels.required) }}>
<Dropdown
items={items}
value={model}
renderValue={renderModelValue}
onChange={(value: any) => setModel(value)}
items={attributionModel}
onChange={onModelChange}
>
{({ value, label }) => {
return <Item key={value}>{label}</Item>;
@ -83,7 +105,7 @@ export function AttributionParameters() {
</Dropdown>
</FormInput>
</FormRow>
{/* <FormRow label={formatMessage(labels.steps)} action={<AddStepButton />}>
<FormRow label={formatMessage(labels.conversionStep)} action={<AddStepButton />}>
<ParameterList>
{steps.map((step: { type: string; value: string }, index: number) => {
return (
@ -100,7 +122,7 @@ export function AttributionParameters() {
<Popup alignment="start">
{(close: () => void) => (
<PopupForm>
<FunnelStepAddForm
<AttributionStepAddForm
type={step.type}
value={step.value}
onChange={handleUpdateStep.bind(null, close, index)}
@ -112,7 +134,7 @@ export function AttributionParameters() {
);
})}
</ParameterList>
</FormRow> */}
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}

View file

@ -9,7 +9,7 @@ import AttributionView from './AttributionView';
const defaultParameters = {
type: REPORT_TYPES.attribution,
parameters: {},
parameters: { model: 'firstClick', steps: [] },
};
export default function AttributionReport({ reportId }: { reportId?: string }) {

View file

@ -0,0 +1,47 @@
import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth';
import { unauthorized, json } from '@/lib/response';
import { parseRequest } from '@/lib/request';
import { getFunnel } from '@/queries';
import { reportParms } from '@/lib/schema';
export async function POST(request: Request) {
const schema = z.object({
...reportParms,
window: z.coerce.number().positive(),
steps: z
.array(
z.object({
type: z.string(),
value: z.string(),
}),
)
.min(2),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const {
websiteId,
steps,
window,
dateRange: { startDate, endDate },
} = body;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getFunnel(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
steps,
windowMinutes: +window,
});
return json(data);
}

View file

@ -263,6 +263,7 @@ export const labels = defineMessages({
id: 'label.utm-description',
defaultMessage: 'Track your campaigns through UTM parameters.',
},
conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion Step' },
steps: { id: 'label.steps', defaultMessage: 'Steps' },
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },

View file

@ -0,0 +1,250 @@
import clickhouse from '@/lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => {
return steps.map((step: { type: string; value: string }, i: number) => {
const visitors = Number(results[i]?.count) || 0;
const previous = Number(results[i - 1]?.count) || 0;
const dropped = previous > 0 ? previous - visitors : 0;
const dropoff = 1 - visitors / previous;
const remaining = visitors / Number(results[0].count);
return {
...step,
visitors,
previous,
dropped,
dropoff,
remaining,
};
});
};
export async function getFunnel(
...args: [
websiteId: string,
criteria: {
windowMinutes: number;
startDate: Date;
endDate: Date;
steps: { type: string; value: string }[];
},
]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(
websiteId: string,
criteria: {
windowMinutes: number;
startDate: Date;
endDate: Date;
steps: { type: string; value: string }[];
},
): Promise<
{
value: string;
visitors: number;
dropoff: number;
}[]
> {
const { windowMinutes, startDate, endDate, steps } = criteria;
const { rawQuery, getAddIntervalQuery } = prisma;
const { levelOneQuery, levelQuery, sumQuery, params } = getFunnelQuery(steps, windowMinutes);
function getFunnelQuery(
steps: { type: string; value: string }[],
windowMinutes: number,
): {
levelOneQuery: string;
levelQuery: string;
sumQuery: string;
params: string[];
} {
return steps.reduce(
(pv, cv, i) => {
const levelNumber = i + 1;
const startSum = i > 0 ? 'union ' : '';
const isURL = cv.type === 'url';
const column = isURL ? 'url_path' : 'event_name';
let operator = '=';
let paramValue = cv.value;
if (cv.value.startsWith('*') || cv.value.endsWith('*')) {
operator = 'like';
paramValue = cv.value.replace(/^\*|\*$/g, '%');
}
if (levelNumber === 1) {
pv.levelOneQuery = `
WITH level1 AS (
select distinct session_id, created_at
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and ${column} ${operator} {{${i}}}
)`;
} else {
pv.levelQuery += `
, level${levelNumber} AS (
select distinct we.session_id, we.created_at
from level${i} l
join website_event we
on l.session_id = we.session_id
where we.website_id = {{websiteId::uuid}}
and we.created_at between l.created_at and ${getAddIntervalQuery(
`l.created_at `,
`${windowMinutes} minute`,
)}
and we.${column} ${operator} {{${i}}}
and we.created_at <= {{endDate}}
)`;
}
pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
pv.params.push(paramValue);
return pv;
},
{
levelOneQuery: '',
levelQuery: '',
sumQuery: '',
params: [],
},
);
}
return rawQuery(
`
${levelOneQuery}
${levelQuery}
${sumQuery}
ORDER BY level;
`,
{
websiteId,
startDate,
endDate,
...params,
},
).then(formatResults(steps));
}
async function clickhouseQuery(
websiteId: string,
criteria: {
windowMinutes: number;
startDate: Date;
endDate: Date;
steps: { type: string; value: string }[];
},
): Promise<
{
value: string;
visitors: number;
dropoff: number;
}[]
> {
const { windowMinutes, startDate, endDate, steps } = criteria;
const { rawQuery } = clickhouse;
const { levelOneQuery, levelQuery, sumQuery, stepFilterQuery, params } = getFunnelQuery(
steps,
windowMinutes,
);
function getFunnelQuery(
steps: { type: string; value: string }[],
windowMinutes: number,
): {
levelOneQuery: string;
levelQuery: string;
sumQuery: string;
stepFilterQuery: string;
params: { [key: string]: string };
} {
return steps.reduce(
(pv, cv, i) => {
const levelNumber = i + 1;
const startSum = i > 0 ? 'union all ' : '';
const startFilter = i > 0 ? 'or' : '';
const isURL = cv.type === 'url';
const column = isURL ? 'url_path' : 'event_name';
let operator = '=';
let paramValue = cv.value;
if (cv.value.startsWith('*') || cv.value.endsWith('*')) {
operator = 'like';
paramValue = cv.value.replace(/^\*|\*$/g, '%');
}
if (levelNumber === 1) {
pv.levelOneQuery = `\n
level1 AS (
select *
from level0
where ${column} ${operator} {param${i}:String}
)`;
} else {
pv.levelQuery += `\n
, level${levelNumber} AS (
select distinct y.session_id as session_id,
y.url_path as url_path,
y.referrer_path as referrer_path,
y.event_name,
y.created_at as created_at
from level${i} x
join level0 y
on x.session_id = y.session_id
where y.created_at between x.created_at and x.created_at + interval ${windowMinutes} minute
and y.${column} ${operator} {param${i}:String}
)`;
}
pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
pv.stepFilterQuery += `${startFilter} ${column} ${operator} {param${i}:String} `;
pv.params[`param${i}`] = paramValue;
return pv;
},
{
levelOneQuery: '',
levelQuery: '',
sumQuery: '',
stepFilterQuery: '',
params: {},
},
);
}
return rawQuery(
`
WITH level0 AS (
select distinct session_id, url_path, referrer_path, event_name, created_at
from umami.website_event
where (${stepFilterQuery})
and website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
),
${levelOneQuery}
${levelQuery}
select *
from (
${sumQuery}
) ORDER BY level;
`,
{
websiteId,
startDate,
endDate,
...params,
},
).then(formatResults(steps));
}