mirror of
https://github.com/umami-software/umami.git
synced 2026-02-10 07:37:11 +01:00
add attribution report params
This commit is contained in:
parent
203e782530
commit
64dcc5af80
5 changed files with 368 additions and 48 deletions
|
|
@ -1,69 +1,91 @@
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import Icons from '@/components/icons';
|
||||||
import { useContext, useState } from 'react';
|
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 BaseParameters from '../[reportId]/BaseParameters';
|
||||||
|
import ParameterList from '../[reportId]/ParameterList';
|
||||||
|
import PopupForm from '../[reportId]/PopupForm';
|
||||||
import { ReportContext } from '../[reportId]/Report';
|
import { ReportContext } from '../[reportId]/Report';
|
||||||
|
import FunnelStepAddForm from '../funnel/FunnelStepAddForm';
|
||||||
|
import styles from './AttributionParameters.module.css';
|
||||||
|
import AttributionStepAddForm from './AttributionStepAddForm';
|
||||||
|
|
||||||
export function AttributionParameters() {
|
export function AttributionParameters() {
|
||||||
const [model, setModel] = useState('firstClick');
|
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
|
||||||
const { report, runReport, isRunning } = useContext(ReportContext);
|
|
||||||
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 queryEnabled = websiteId && dateRange;
|
const queryEnabled = websiteId && dateRange && steps.length > 0;
|
||||||
|
const [model, setModel] = useState('');
|
||||||
|
|
||||||
const handleSubmit = (data: any, e: any) => {
|
const handleSubmit = (data: any, e: any) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
runReport(data);
|
runReport(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
// const handleAddStep = (step: { type: string; value: string }) => {
|
const handleAddStep = (step: { type: string; value: string }) => {
|
||||||
// updateReport({ parameters: { steps: parameters.steps.concat(step) } });
|
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
|
||||||
// };
|
};
|
||||||
|
|
||||||
// const handleUpdateStep = (
|
const handleUpdateStep = (
|
||||||
// close: () => void,
|
close: () => void,
|
||||||
// index: number,
|
index: number,
|
||||||
// step: { type: string; value: string },
|
step: { type: string; value: string },
|
||||||
// ) => {
|
) => {
|
||||||
// const steps = [...parameters.steps];
|
const steps = [...parameters.steps];
|
||||||
// steps[index] = step;
|
steps[index] = step;
|
||||||
// updateReport({ parameters: { steps } });
|
updateReport({ parameters: { steps } });
|
||||||
// close();
|
close();
|
||||||
// };
|
};
|
||||||
|
|
||||||
// const handleRemoveStep = (index: number) => {
|
const handleRemoveStep = (index: number) => {
|
||||||
// const steps = [...parameters.steps];
|
const steps = [...parameters.steps];
|
||||||
// delete steps[index];
|
delete steps[index];
|
||||||
// updateReport({ parameters: { steps: steps.filter(n => n) } });
|
updateReport({ parameters: { steps: steps.filter(n => n) } });
|
||||||
// };
|
};
|
||||||
|
|
||||||
// const AddStepButton = () => {
|
const AddStepButton = () => {
|
||||||
// return (
|
return (
|
||||||
// <PopupTrigger>
|
<PopupTrigger disabled={steps.length > 0}>
|
||||||
// <Button>
|
<Button disabled={steps.length > 0}>
|
||||||
// <Icon>
|
<Icon>
|
||||||
// <Icons.Plus />
|
<Icons.Plus />
|
||||||
// </Icon>
|
</Icon>
|
||||||
// </Button>
|
</Button>
|
||||||
// <Popup alignment="start">
|
<Popup alignment="start">
|
||||||
// <PopupForm>
|
<PopupForm>
|
||||||
// <FunnelStepAddForm onChange={handleAddStep} />
|
<FunnelStepAddForm onChange={handleAddStep} />
|
||||||
// </PopupForm>
|
</PopupForm>
|
||||||
// </Popup>
|
</Popup>
|
||||||
// </PopupTrigger>
|
</PopupTrigger>
|
||||||
// );
|
);
|
||||||
// };
|
};
|
||||||
|
|
||||||
const attributionModel = [
|
const items = [
|
||||||
{ label: 'First-Click', value: 'firstClick' },
|
{ label: 'First-Click', value: 'firstClick' },
|
||||||
{ label: 'Last-Click', value: 'lastClick' },
|
{ label: 'Last-Click', value: 'lastClick' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderModelValue = (value: any) => {
|
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 (
|
return (
|
||||||
|
|
@ -72,10 +94,10 @@ export function AttributionParameters() {
|
||||||
<FormRow label={formatMessage(labels.model)}>
|
<FormRow label={formatMessage(labels.model)}>
|
||||||
<FormInput name="model" rules={{ required: formatMessage(labels.required) }}>
|
<FormInput name="model" rules={{ required: formatMessage(labels.required) }}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
items={items}
|
||||||
value={model}
|
value={model}
|
||||||
renderValue={renderModelValue}
|
renderValue={renderModelValue}
|
||||||
onChange={(value: any) => setModel(value)}
|
onChange={onModelChange}
|
||||||
items={attributionModel}
|
|
||||||
>
|
>
|
||||||
{({ value, label }) => {
|
{({ value, label }) => {
|
||||||
return <Item key={value}>{label}</Item>;
|
return <Item key={value}>{label}</Item>;
|
||||||
|
|
@ -83,7 +105,7 @@ export function AttributionParameters() {
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</FormInput>
|
</FormInput>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
{/* <FormRow label={formatMessage(labels.steps)} action={<AddStepButton />}>
|
<FormRow label={formatMessage(labels.conversionStep)} action={<AddStepButton />}>
|
||||||
<ParameterList>
|
<ParameterList>
|
||||||
{steps.map((step: { type: string; value: string }, index: number) => {
|
{steps.map((step: { type: string; value: string }, index: number) => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -100,7 +122,7 @@ export function AttributionParameters() {
|
||||||
<Popup alignment="start">
|
<Popup alignment="start">
|
||||||
{(close: () => void) => (
|
{(close: () => void) => (
|
||||||
<PopupForm>
|
<PopupForm>
|
||||||
<FunnelStepAddForm
|
<AttributionStepAddForm
|
||||||
type={step.type}
|
type={step.type}
|
||||||
value={step.value}
|
value={step.value}
|
||||||
onChange={handleUpdateStep.bind(null, close, index)}
|
onChange={handleUpdateStep.bind(null, close, index)}
|
||||||
|
|
@ -112,7 +134,7 @@ export function AttributionParameters() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ParameterList>
|
</ParameterList>
|
||||||
</FormRow> */}
|
</FormRow>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}>
|
<SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}>
|
||||||
{formatMessage(labels.runQuery)}
|
{formatMessage(labels.runQuery)}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import AttributionView from './AttributionView';
|
||||||
|
|
||||||
const defaultParameters = {
|
const defaultParameters = {
|
||||||
type: REPORT_TYPES.attribution,
|
type: REPORT_TYPES.attribution,
|
||||||
parameters: {},
|
parameters: { model: 'firstClick', steps: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AttributionReport({ reportId }: { reportId?: string }) {
|
export default function AttributionReport({ reportId }: { reportId?: string }) {
|
||||||
|
|
|
||||||
47
src/app/api/reports/attribution/route.ts
Normal file
47
src/app/api/reports/attribution/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -263,6 +263,7 @@ export const labels = defineMessages({
|
||||||
id: 'label.utm-description',
|
id: 'label.utm-description',
|
||||||
defaultMessage: 'Track your campaigns through UTM parameters.',
|
defaultMessage: 'Track your campaigns through UTM parameters.',
|
||||||
},
|
},
|
||||||
|
conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion Step' },
|
||||||
steps: { id: 'label.steps', defaultMessage: 'Steps' },
|
steps: { id: 'label.steps', defaultMessage: 'Steps' },
|
||||||
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
|
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
|
||||||
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
|
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
|
||||||
|
|
|
||||||
250
src/queries/sql/reports/getAttribution.ts
Normal file
250
src/queries/sql/reports/getAttribution.ts
Normal 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));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue