diff --git a/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx b/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx
index 1a131da13c..192e51acee 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx
@@ -10,7 +10,13 @@ import WebsiteHeader from './WebsiteHeader';
import WebsiteMetricsBar from './WebsiteMetricsBar';
import WebsiteTableView from './WebsiteTableView';
-export default function WebsiteDetails({ websiteId }: { websiteId: string }) {
+export default function WebsiteDetails({
+ websiteId,
+ customDataFields,
+}: {
+ websiteId: string;
+ customDataFields: string[];
+}) {
const { data: website, isLoading, error } = useWebsite(websiteId);
const pathname = usePathname();
const { query } = useNavigation();
@@ -31,7 +37,13 @@ export default function WebsiteDetails({ websiteId }: { websiteId: string }) {
{!website && }
{website && (
<>
- {!view && }
+ {!view && (
+
+ )}
{view && }
>
)}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx
index 7cc415e5cd..9cabcf7de1 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx
@@ -9,13 +9,17 @@ import WorldMap from 'components/metrics/WorldMap';
import CountriesTable from 'components/metrics/CountriesTable';
import EventsTable from 'components/metrics/EventsTable';
import EventsChart from 'components/metrics/EventsChart';
+import PageviewCustomDataTable from 'components/metrics/PageviewCustomDataTable';
+import { chunkArray } from 'next-basics';
export default function WebsiteTableView({
websiteId,
domainName,
+ customDataFields,
}: {
websiteId: string;
domainName: string;
+ customDataFields: string[];
}) {
const [countryData, setCountryData] = useState();
const tableProps = {
@@ -35,6 +39,16 @@ export default function WebsiteTableView({
+ {chunkArray(customDataFields, 3).map((fields, index) => (
+
+ {fields.map(field => (
+
+ ))}
+
+ ))}
diff --git a/src/app/(main)/websites/[websiteId]/page.tsx b/src/app/(main)/websites/[websiteId]/page.tsx
index ddb6c833b1..6cc8471421 100644
--- a/src/app/(main)/websites/[websiteId]/page.tsx
+++ b/src/app/(main)/websites/[websiteId]/page.tsx
@@ -1,8 +1,8 @@
import WebsiteDetails from './WebsiteDetails';
import { Metadata } from 'next';
-export default function WebsitePage({ params: { websiteId } }) {
- return ;
+export default function WebsitePage({ params: { websiteId }, searchParams }) {
+ return ;
}
export const metadata: Metadata = {
diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx
index 26355018ff..a8a77c7881 100644
--- a/src/components/metrics/MetricsTable.tsx
+++ b/src/components/metrics/MetricsTable.tsx
@@ -29,6 +29,7 @@ export interface MetricsTableProps extends ListTableProps {
onSearch?: (search: string) => void;
allowSearch?: boolean;
children?: ReactNode;
+ fieldName?: string
}
export function MetricsTable({
@@ -41,6 +42,7 @@ export function MetricsTable({
delay = null,
allowSearch = false,
children,
+ fieldName,
...props
}: MetricsTableProps) {
const [search, setSearch] = useState('');
@@ -70,6 +72,7 @@ export function MetricsTable({
city,
limit,
search,
+ fieldName,
},
{ retryDelay: delay || DEFAULT_ANIMATION_DURATION, onDataLoad },
);
diff --git a/src/components/metrics/PageviewCustomDataTable.tsx b/src/components/metrics/PageviewCustomDataTable.tsx
new file mode 100644
index 0000000000..c7cffa76a6
--- /dev/null
+++ b/src/components/metrics/PageviewCustomDataTable.tsx
@@ -0,0 +1,29 @@
+import useMessages from 'components/hooks/useMessages';
+import MetricsTable, { MetricsTableProps } from 'components/metrics/MetricsTable';
+
+export function PageviewCustomDataTable(props: MetricsTableProps) {
+ const { formatMessage, labels } = useMessages();
+
+ function renderLink({ x: field }) {
+ return <>{field}>;
+ }
+
+ function titleize(fieldName: string) {
+ return fieldName
+ .split(/[_-]/)
+ .map((word, i) => (i === 0 ? word[0].toUpperCase() + word.slice(1) : word))
+ .join(' ');
+ }
+
+ return (
+
+ );
+}
+
+export default PageviewCustomDataTable;
diff --git a/src/pages/api/websites/[websiteId]/metrics.ts b/src/pages/api/websites/[websiteId]/metrics.ts
index 4a881ef91b..1c9b4774e5 100644
--- a/src/pages/api/websites/[websiteId]/metrics.ts
+++ b/src/pages/api/websites/[websiteId]/metrics.ts
@@ -28,6 +28,7 @@ export interface WebsiteMetricsRequestQuery {
limit?: number;
offset?: number;
search?: string;
+ fieldName?: string;
}
const schema = {
@@ -51,6 +52,7 @@ const schema = {
limit: yup.number(),
offset: yup.number(),
search: yup.string(),
+ fieldName: yup.string(),
}),
};
@@ -62,7 +64,26 @@ export default async (
await useAuth(req, res);
await useValidate(schema, req, res);
- const { websiteId, type, limit, offset, search } = req.query;
+ const {
+ websiteId,
+ type,
+ url,
+ referrer,
+ title,
+ query,
+ os,
+ browser,
+ device,
+ country,
+ region,
+ city,
+ language,
+ event,
+ limit,
+ offset,
+ search,
+ fieldName,
+ } = req.query;
if (req.method === 'GET') {
if (!(await canViewWebsite(req.auth, websiteId))) {
@@ -111,6 +132,10 @@ export default async (
if (EVENT_COLUMNS.includes(type)) {
const data = await getPageviewMetrics(websiteId, type, filters, limit, offset);
+ return ok(res, data);
+ } else if (type === 'custom') {
+ const data = await getPageviewMetrics(websiteId, column, filters, limit, offset, fieldName);
+
return ok(res, data);
}
diff --git a/src/queries/analytics/eventData/getEventDataEvents.ts b/src/queries/analytics/eventData/getEventDataEvents.ts
index f869deae49..3dc4afcfc1 100644
--- a/src/queries/analytics/eventData/getEventDataEvents.ts
+++ b/src/queries/analytics/eventData/getEventDataEvents.ts
@@ -51,6 +51,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
on website_event.event_id = event_data.website_event_id
where event_data.website_id = {{websiteId::uuid}}
and event_data.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_name is not null
group by website_event.event_name, event_data.data_key, event_data.data_type
order by 1 asc, 2 asc
limit 500
diff --git a/src/queries/analytics/eventData/getEventDataStats.ts b/src/queries/analytics/eventData/getEventDataStats.ts
index 978f561bc9..64456ebcbf 100644
--- a/src/queries/analytics/eventData/getEventDataStats.ts
+++ b/src/queries/analytics/eventData/getEventDataStats.ts
@@ -32,8 +32,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
data_key,
count(*) as "total"
from event_data
- where website_id = {{websiteId::uuid}}
- and created_at between {{startDate}} and {{endDate}}
+ join website_event on website_event.event_id = event_data.website_event_id
+ where website_event.website_id = {{websiteId::uuid}}
+ and event_data.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_name is not null
${filterQuery}
group by website_event_id, data_key
) as t
diff --git a/src/queries/analytics/pageviews/getPageviewMetrics.ts b/src/queries/analytics/pageviews/getPageviewMetrics.ts
index eaf4ae324a..1c2da63b8c 100644
--- a/src/queries/analytics/pageviews/getPageviewMetrics.ts
+++ b/src/queries/analytics/pageviews/getPageviewMetrics.ts
@@ -5,7 +5,14 @@ import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from 'lib/constants';
import { QueryFilters } from 'lib/types';
export async function getPageviewMetrics(
- ...args: [websiteId: string, type: string, filters: QueryFilters, limit?: number, offset?: number]
+ ...args: [
+ websiteId: string,
+ column: string,
+ filters: QueryFilters,
+ limit?: number,
+ offset?: number,
+ fieldName?: string,
+ ]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
@@ -19,6 +26,7 @@ async function relationalQuery(
filters: QueryFilters,
limit: number = 500,
offset: number = 0,
+ fieldName?: string,
) {
const column = FILTER_COLUMNS[type] || type;
const { rawQuery, parseFilters } = prisma;
@@ -32,19 +40,26 @@ async function relationalQuery(
);
let excludeDomain = '';
+ let joinEventData = '';
+ let filterEventDataOnFieldName = '';
if (column === 'referrer_domain') {
excludeDomain =
'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)';
+ } else if (column === 'custom') {
+ joinEventData = 'join event_data on event_data.website_event_id = website_event.event_id';
+ filterEventDataOnFieldName = 'and event_data.event_key = {{fieldName}}';
}
return rawQuery(
`
- select ${column} x, count(*) y
+ select ${column === 'custom' ? `event_data.string_value` : column} x, count(*) y
from website_event
+ ${joinEventData}
${joinSession}
where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}}
and event_type = {{eventType}}
+ ${filterEventDataOnFieldName}
${excludeDomain}
${filterQuery}
group by 1
@@ -52,7 +67,7 @@ async function relationalQuery(
limit ${limit}
offset ${offset}
`,
- params,
+ { ...params, fieldName },
);
}
@@ -62,6 +77,8 @@ async function clickhouseQuery(
filters: QueryFilters,
limit: number = 500,
offset: number = 0,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ fieldName?: string,
): Promise<{ x: string; y: number }[]> {
const column = FILTER_COLUMNS[type] || type;
const { rawQuery, parseFilters } = clickhouse;
diff --git a/src/tracker/index.js b/src/tracker/index.js
index cc6b8dbbfe..f4c8ea1353 100644
--- a/src/tracker/index.js
+++ b/src/tracker/index.js
@@ -28,6 +28,8 @@
const endpoint = `${host.replace(/\/$/, '')}__COLLECT_API_ENDPOINT__`;
const screen = `${width}x${height}`;
const eventRegex = /data-umami-event-([\w-_]+)/;
+ const pageviewCustomPropertyRegex = /data-([\w-_]+)/;
+ const reservedDataAttributes = ['website-id', 'domains', 'umami-event', 'auto-track', 'host-url', 'exclude-search'];
const eventNameAttribute = _data + 'umami-event';
const delayDuration = 300;
@@ -61,6 +63,20 @@
return excludeSearch ? url.split('?')[0] : url;
};
+ const getPageviewEventData = () =>
+ Object.fromEntries(
+ Array.from(currentScript.attributes)
+ .filter(attribute => attribute.name.match(pageviewCustomPropertyRegex))
+ .filter(
+ attribute =>
+ !reservedDataAttributes.some(reserved => _data + reserved === attribute.name),
+ )
+ .map(attribute => {
+ const match = attribute.name.match(pageviewCustomPropertyRegex);
+ return [match[1], attribute.value];
+ }),
+ );
+
const getPayload = () => ({
website,
hostname,
@@ -230,7 +246,7 @@
} else if (typeof obj === 'function') {
return send(obj(getPayload()));
}
- return send(getPayload());
+ return send({ ...getPayload(), data: getPageviewEventData() });
};
const identify = data => send({ ...getPayload(), data }, 'identify');