diff --git a/.eslintrc.json b/.eslintrc.json
index bcc38cf8..99c7e132 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -36,7 +36,7 @@
["store", "./store"],
["styles", "./styles"]
],
- "extensions": [".ts", ".js", ".jsx", ".json"]
+ "extensions": [".ts", ".tsx", ".js", ".jsx", ".json"]
}
}
},
diff --git a/components/layout/NavBar.module.css b/components/layout/NavBar.module.css
index da042ff7..b0d546bd 100644
--- a/components/layout/NavBar.module.css
+++ b/components/layout/NavBar.module.css
@@ -5,7 +5,7 @@
align-items: center;
height: 60px;
background: var(--base75);
- border-bottom: 2px solid var(--base300);
+ border-bottom: 1px solid var(--base300);
padding: 0 20px;
}
diff --git a/components/metrics/BarChart.js b/components/metrics/BarChart.js
index 65881e28..3065247a 100644
--- a/components/metrics/BarChart.js
+++ b/components/metrics/BarChart.js
@@ -52,7 +52,7 @@ export default function BarChart({
millisecond: 'T',
second: 'pp',
minute: 'p',
- hour: 'h aaa',
+ hour: 'h:mm aaa - PP',
day: 'PPPP',
week: 'PPPP',
month: 'LLLL yyyy',
@@ -135,15 +135,13 @@ export default function BarChart({
},
},
};
- }, [animationDuration, renderTooltip, stacked, colors]);
+ }, [animationDuration, renderTooltip, stacked, colors, unit]);
const createChart = () => {
Chart.defaults.font.family = 'Inter';
const options = getOptions();
- onCreate(options);
-
chart.current = new Chart(canvas.current, {
type: 'bar',
data: {
@@ -151,6 +149,8 @@ export default function BarChart({
},
options,
});
+
+ onCreate(chart.current);
};
const updateChart = () => {
@@ -158,9 +158,11 @@ export default function BarChart({
chart.current.options = getOptions();
- onUpdate(chart.current);
+ chart.current.data.datasets = datasets;
chart.current.update();
+
+ onUpdate(chart.current);
};
useEffect(() => {
diff --git a/components/metrics/EventsChart.js b/components/metrics/EventsChart.js
index 6ccab9ab..1fc461bb 100644
--- a/components/metrics/EventsChart.js
+++ b/components/metrics/EventsChart.js
@@ -17,7 +17,7 @@ export default function EventsChart({ websiteId, className, token }) {
query: { url, eventName },
} = usePageQuery();
- const { data, isLoading } = useQuery(['events', { websiteId, modified, eventName }], () =>
+ const { data, isLoading } = useQuery(['events', websiteId, modified, eventName], () =>
get(`/websites/${websiteId}/events`, {
startAt: +startDate,
endAt: +endDate,
@@ -33,12 +33,12 @@ export default function EventsChart({ websiteId, className, token }) {
if (!data) return [];
if (isLoading) return data;
- const map = data.reduce((obj, { x, y }) => {
+ const map = data.reduce((obj, { x, t, y }) => {
if (!obj[x]) {
obj[x] = [];
}
- obj[x].push({ x, y });
+ obj[x].push({ x: t, y });
return obj;
}, {});
@@ -58,22 +58,12 @@ export default function EventsChart({ websiteId, className, token }) {
borderWidth: 1,
};
});
- }, [data, isLoading]);
-
- function handleUpdate(chart) {
- chart.data.datasets = datasets;
-
- chart.update();
- }
+ }, [data, isLoading, startDate, endDate, unit]);
if (isLoading) {
return ;
}
- if (!data) {
- return null;
- }
-
return (
diff --git a/components/metrics/PageviewsChart.js b/components/metrics/PageviewsChart.js
index 43349006..f9cf52b0 100644
--- a/components/metrics/PageviewsChart.js
+++ b/components/metrics/PageviewsChart.js
@@ -38,35 +38,24 @@ export default function PageviewsChart({
};
}, [theme]);
- const handleUpdate = chart => {
- const {
- data: { datasets },
- } = chart;
+ const datasets = useMemo(() => {
+ if (!data) return [];
- datasets[0].data = data.sessions;
- datasets[0].label = formatMessage(labels.uniqueVisitors);
- datasets[1].data = data.pageviews;
- datasets[1].label = formatMessage(labels.pageViews);
- };
-
- if (!data) {
- return null;
- }
-
- const datasets = [
- {
- label: formatMessage(labels.uniqueVisitors),
- data: data.sessions,
- borderWidth: 1,
- ...colors.visitors,
- },
- {
- label: formatMessage(labels.pageViews),
- data: data.pageviews,
- borderWidth: 1,
- ...colors.views,
- },
- ];
+ return [
+ {
+ label: formatMessage(labels.uniqueVisitors),
+ data: data.sessions,
+ borderWidth: 1,
+ ...colors.visitors,
+ },
+ {
+ label: formatMessage(labels.pageViews),
+ data: data.pageviews,
+ borderWidth: 1,
+ ...colors.views,
+ },
+ ];
+ }, [data]);
return (
@@ -78,7 +67,6 @@ export default function PageviewsChart({
unit={unit}
records={records}
animationDuration={visible ? animationDuration : 0}
- onUpdate={handleUpdate}
loading={loading}
/>
diff --git a/components/pages/console/TestConsole.js b/components/pages/console/TestConsole.js
index c7e41c62..391260e5 100644
--- a/components/pages/console/TestConsole.js
+++ b/components/pages/console/TestConsole.js
@@ -1,13 +1,13 @@
+import { Button, Column, Row } from 'react-basics';
+import Script from 'next/script';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
import WebsiteSelect from 'components/input/WebsiteSelect';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import EventsChart from 'components/metrics/EventsChart';
import WebsiteChart from 'components/metrics/WebsiteChart';
import useApi from 'hooks/useApi';
-import Head from 'next/head';
-import Link from 'next/link';
-import { useRouter } from 'next/router';
-import { Button, Column, Row } from 'react-basics';
import styles from './TestConsole.module.css';
export default function TestConsole() {
@@ -24,22 +24,23 @@ export default function TestConsole() {
}
function handleClick() {
- window.umami('umami-default');
- window.umami.trackView('/page-view', 'https://www.google.com');
- window.umami.trackEvent('track-event-no-data');
- window.umami.trackEvent('track-event-with-data', {
- test: 'test-data',
- time: new Date(),
- number: 1,
- time2: new Date().toISOString(),
- nested: {
+ window.umami.track({ url: '/page-view', referrer: 'https://www.google.com' });
+ window.umami.track('track-event-no-data');
+ window.umami.track('track-event-with-data', {
+ data: {
test: 'test-data',
+ time: new Date(),
number: 1,
- object: {
+ time2: new Date().toISOString(),
+ nested: {
test: 'test-data',
+ number: 1,
+ object: {
+ test: 'test-data',
+ },
},
+ array: [1, 2, 3],
},
- array: [1, 2, 3],
});
}
@@ -52,22 +53,17 @@ export default function TestConsole() {
return (
-
- {typeof window !== 'undefined' && website && (
-
- )}
-
{website && (
<>
+
Page links
@@ -78,14 +74,14 @@ export default function TestConsole() {
page two
- CSS events
-
Javascript events
@@ -108,14 +114,12 @@ export default function TestConsole() {
- Statistics
- Events
diff --git a/components/pages/console/TestConsole.module.css b/components/pages/console/TestConsole.module.css
index c8ab1a61..9b10a2b2 100644
--- a/components/pages/console/TestConsole.module.css
+++ b/components/pages/console/TestConsole.module.css
@@ -1,5 +1,5 @@
.test {
- border: 1px solid var(--base200);
+ border: 1px solid var(--base400);
border-radius: 5px;
padding: 0 20px 20px 20px;
}
@@ -7,9 +7,5 @@
.header {
font-size: 16px;
font-weight: 700;
- line-height: 90px;
-}
-
-.hidden {
- transform: rotate(-90deg);
+ margin: 20px 0;
}
diff --git a/pages/api/send.ts b/pages/api/send.ts
index 1a880a46..f6c8a321 100644
--- a/pages/api/send.ts
+++ b/pages/api/send.ts
@@ -1,7 +1,7 @@
import isbot from 'isbot';
import ipaddr from 'ipaddr.js';
import { createToken, ok, send, badRequest, forbidden } from 'next-basics';
-import { savePageView, saveEvent } from 'queries';
+import { saveEvent } from 'queries';
import { useCors, useSession } from 'lib/middleware';
import { getJsonBody, getIpAddress } from 'lib/detect';
import { secret } from 'lib/crypto';
@@ -34,11 +34,15 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
const { type, payload } = getJsonBody(req);
- const { url, referrer, eventName, eventData, pageTitle } = payload;
+ if (type !== 'event') {
+ return badRequest(res);
+ }
+
+ const { url, referrer, name: eventName, data: eventData, title: pageTitle } = payload;
// Validate eventData is JSON
if (eventData && !(typeof eventData === 'object' && !Array.isArray(eventData))) {
- return badRequest(res, 'Event Data must be in the form of a JSON Object.');
+ return badRequest(res, 'Invalid event data.');
}
const ignoreIps = process.env.IGNORE_IP;
@@ -87,48 +91,34 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
const session = req.session;
- let urlPath = url.split('?')[0];
- const urlQuery = url.split('?')[1];
- let referrerPath;
- let referrerQuery;
+ // eslint-disable-next-line prefer-const
+ let [urlPath, urlQuery] = url?.split('?') || [];
+ let [referrerPath, referrerQuery] = referrer?.split('?') || [];
let referrerDomain;
- try {
- const newRef = new URL(referrer);
- referrerPath = newRef.pathname;
- referrerDomain = newRef.hostname;
- referrerQuery = newRef.search.substring(1);
- } catch {
- referrerPath = referrer?.split('?')[0];
- referrerQuery = referrer?.split('?')[1];
+ if (referrerPath.startsWith('http')) {
+ const refUrl = new URL(referrer);
+ referrerPath = refUrl.pathname;
+ referrerQuery = refUrl.search.substring(1);
+ referrerDomain = refUrl.hostname;
}
if (process.env.REMOVE_TRAILING_SLASH) {
urlPath = urlPath.replace(/\/$/, '');
}
- if (type === 'pageview') {
- await savePageView({
- ...session,
- urlPath,
- urlQuery,
- referrerPath,
- referrerQuery,
- referrerDomain,
- pageTitle,
- });
- } else if (type === 'event') {
- await saveEvent({
- ...session,
- urlPath,
- urlQuery,
- pageTitle,
- eventName,
- eventData,
- });
- } else {
- return badRequest(res);
- }
+ await saveEvent({
+ urlPath,
+ urlQuery,
+ referrerPath,
+ referrerQuery,
+ referrerDomain,
+ pageTitle,
+ eventName,
+ eventData,
+ ...session,
+ sessionId: session.id,
+ });
const token = createToken(session, secret());
diff --git a/queries/analytics/event/saveEvent.ts b/queries/analytics/event/saveEvent.ts
index cbb48826..1749dca5 100644
--- a/queries/analytics/event/saveEvent.ts
+++ b/queries/analytics/event/saveEvent.ts
@@ -3,13 +3,16 @@ import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
import kafka from 'lib/kafka';
import prisma from 'lib/prisma';
import { uuid } from 'lib/crypto';
-import { saveEventData } from '../eventData/saveEventData';
+import { saveEventData } from 'queries/analytics/eventData/saveEventData';
export async function saveEvent(args: {
- id: string;
+ sessionId: string;
websiteId: string;
urlPath: string;
urlQuery?: string;
+ referrerPath?: string;
+ referrerQuery?: string;
+ referrerDomain?: string;
pageTitle?: string;
eventName?: string;
eventData?: any;
@@ -31,7 +34,7 @@ export async function saveEvent(args: {
}
async function relationalQuery(data: {
- id: string;
+ sessionId: string;
websiteId: string;
urlPath: string;
urlQuery?: string;
@@ -39,7 +42,7 @@ async function relationalQuery(data: {
eventName?: string;
eventData?: any;
}) {
- const { websiteId, id: sessionId, urlPath, urlQuery, eventName, eventData, pageTitle } = data;
+ const { websiteId, sessionId, urlPath, urlQuery, eventName, eventData, pageTitle } = data;
const websiteEventId = uuid();
const websiteEvent = prisma.client.websiteEvent.create({
@@ -49,9 +52,9 @@ async function relationalQuery(data: {
sessionId,
urlPath: urlPath?.substring(0, URL_LENGTH),
urlQuery: urlQuery?.substring(0, URL_LENGTH),
- pageTitle: pageTitle,
- eventType: EVENT_TYPE.customEvent,
- eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
+ pageTitle,
+ eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
+ eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
},
});
@@ -70,7 +73,7 @@ async function relationalQuery(data: {
}
async function clickhouseQuery(data: {
- id: string;
+ sessionId: string;
websiteId: string;
urlPath: string;
urlQuery?: string;
@@ -90,7 +93,7 @@ async function clickhouseQuery(data: {
}) {
const {
websiteId,
- id: sessionId,
+ sessionId,
urlPath,
urlQuery,
pageTitle,
@@ -100,7 +103,6 @@ async function clickhouseQuery(data: {
subdivision1,
subdivision2,
city,
- ...args
} = data;
const { getDateFormat, sendMessage } = kafka;
const eventId = uuid();
@@ -117,10 +119,9 @@ async function clickhouseQuery(data: {
url_path: urlPath?.substring(0, URL_LENGTH),
url_query: urlQuery?.substring(0, URL_LENGTH),
page_title: pageTitle,
- event_type: EVENT_TYPE.customEvent,
- event_name: eventName?.substring(0, EVENT_NAME_LENGTH),
+ event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
+ event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
created_at: createdAt,
- ...args,
};
await sendMessage(message, 'event');
diff --git a/queries/index.js b/queries/index.js
index d98f2609..30e63086 100644
--- a/queries/index.js
+++ b/queries/index.js
@@ -9,7 +9,6 @@ export * from './analytics/event/saveEvent';
export * from './analytics/pageview/getPageviewMetrics';
export * from './analytics/pageview/getPageviews';
export * from './analytics/pageview/getPageviewStats';
-export * from './analytics/pageview/savePageView';
export * from './analytics/session/createSession';
export * from './analytics/session/getSession';
export * from './analytics/session/getSessionMetrics';
diff --git a/tracker/index.js b/tracker/index.js
index f3c05f0e..698dd09e 100644
--- a/tracker/index.js
+++ b/tracker/index.js
@@ -12,12 +12,24 @@
if (!currentScript) return;
- const assign = (a, b) => {
- Object.keys(b).forEach(key => {
- if (b[key] !== undefined) a[key] = b[key];
- });
- return a;
- };
+ const delayDuration = 300;
+ const _data = 'data-';
+ const _false = 'false';
+ const attr = currentScript.getAttribute.bind(currentScript);
+ const website = attr(_data + 'website-id');
+ const hostUrl = attr(_data + 'host-url');
+ const autoTrack = attr(_data + 'auto-track') !== _false;
+ const dnt = attr(_data + 'do-not-track');
+ const domain = attr(_data + 'domains') || '';
+ const domains = domain.split(',').map(n => n.trim());
+ const root = hostUrl
+ ? hostUrl.replace(/\/$/, '')
+ : currentScript.src.split('/').slice(0, -1).join('/');
+ const endpoint = `${root}/api/send`;
+ const screen = `${width}x${height}`;
+ const eventRegex = /data-umami-event-([\w-_]+)/;
+
+ /* Helper functions */
const hook = (_this, method, callback) => {
const orig = _this[method];
@@ -29,6 +41,25 @@
};
};
+ const getPath = url => {
+ if (url.substring(0, 4) === 'http') {
+ return '/' + url.split('/').splice(3).join('/');
+ }
+ return url;
+ };
+
+ const getPayload = () => ({
+ website,
+ hostname,
+ screen,
+ language,
+ title,
+ url: currentUrl,
+ referrer: currentRef,
+ });
+
+ /* Tracking functions */
+
const doNotTrack = () => {
const { doNotTrack, navigator, external } = window;
@@ -47,212 +78,121 @@
(dnt && doNotTrack()) ||
(domain && !domains.includes(hostname));
- const delayDuration = 300;
- const _data = 'data-';
- const _false = 'false';
- const attr = currentScript.getAttribute.bind(currentScript);
- const website = attr(_data + 'website-id');
- const hostUrl = attr(_data + 'host-url');
- const autoTrack = attr(_data + 'auto-track') !== _false;
- const dnt = attr(_data + 'do-not-track');
- const cssEvents = attr(_data + 'css-events') !== _false;
- const domain = attr(_data + 'domains') || '';
- const domains = domain.split(',').map(n => n.trim());
- const root = hostUrl
- ? hostUrl.replace(/\/$/, '')
- : currentScript.src.split('/').slice(0, -1).join('/');
- const endpoint = `${root}/api/send`;
- const screen = `${width}x${height}`;
- const eventClass = /^umami--([a-z]+)--([\w]+[\w-]*)$/;
- const eventSelect = "[class*='umami--']";
+ const handlePush = (state, title, url) => {
+ if (!url) return;
- let listeners = {};
- let currentUrl = `${pathname}${search}`;
- let currentRef = document.referrer;
- let currentPageTitle = document.title;
- let cache;
+ currentRef = currentUrl;
+ currentUrl = getPath(url.toString());
- if (currentRef.substring(0, 4) === 'http') {
- if (currentRef.split('/')[2].split(':')[0] === hostname) {
- currentRef = '/' + currentRef.split('/').splice(3).join('/');
+ if (currentUrl !== currentRef) {
+ setTimeout(track, delayDuration);
}
- }
+ };
- /* Collect metrics */
+ const handleClick = () => {
+ const callback = e => {
+ const t = e.target;
+ const attr = t.getAttribute.bind(t);
+ const eventName = attr(_data + 'umami-event');
- const getPayload = () => ({
- website,
- hostname,
- screen,
- language,
- url: currentUrl,
- });
+ if (eventName) {
+ const eventData = {};
- const collect = (type, payload) => {
+ t.getAttributeNames().forEach(name => {
+ const match = name.match(eventRegex);
+
+ if (match) {
+ eventData[match[1]] = attr(name);
+ }
+ });
+
+ if (t.tagName === 'A') {
+ const href = attr('href');
+ const target = attr('target');
+
+ if (
+ href &&
+ target !== '_blank' &&
+ !(e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1))
+ ) {
+ e.preventDefault();
+ track(eventName, { data: eventData }).then(() => {
+ location.href = href;
+ });
+ return;
+ }
+ }
+
+ track(eventName, { data: eventData });
+ }
+ };
+
+ document.addEventListener('click', callback, true);
+ };
+
+ const observeTitle = () => {
+ const callback = ([entry]) => {
+ title = entry.target.data;
+ };
+
+ const observer = new MutationObserver(callback);
+
+ observer.observe(document.querySelector('head > title'), {
+ subtree: true,
+ characterData: true,
+ });
+ };
+
+ const send = payload => {
if (trackingDisabled()) return;
return fetch(endpoint, {
method: 'POST',
- body: JSON.stringify({ type, payload }),
- headers: assign({ 'Content-Type': 'application/json' }, { ['x-umami-cache']: cache }),
+ body: JSON.stringify({ type: 'event', payload }),
+ headers: { 'Content-Type': 'application/json', ['x-umami-cache']: cache },
})
.then(res => res.text())
.then(text => (cache = text));
};
- const trackView = (
- url = currentUrl,
- referrer = currentRef,
- websiteId = website,
- pageTitle = currentPageTitle,
- ) =>
- collect(
- 'pageview',
- assign(getPayload(), {
- website: websiteId,
- url,
- referrer,
- pageTitle,
- }),
- );
-
- const trackEvent = (
- eventName,
- eventData,
- url = currentUrl,
- websiteId = website,
- pageTitle = currentPageTitle,
- ) =>
- collect(
- 'event',
- assign(getPayload(), {
- website: websiteId,
- url,
- pageTitle,
- eventName,
- eventData,
- }),
- );
-
- /* Handle events */
-
- const addEvents = node => {
- const elements = node.querySelectorAll(eventSelect);
- Array.prototype.forEach.call(elements, addEvent);
- };
-
- const addEvent = element => {
- const get = element.getAttribute.bind(element);
- (get('class') || '').split(' ').forEach(className => {
- if (!eventClass.test(className)) return;
-
- const [, event, name] = className.split('--');
-
- const listener = listeners[className]
- ? listeners[className]
- : (listeners[className] = e => {
- if (
- event === 'click' &&
- element.tagName === 'A' &&
- !(
- e.ctrlKey ||
- e.shiftKey ||
- e.metaKey ||
- (e.button && e.button === 1) ||
- get('target')
- )
- ) {
- e.preventDefault();
- trackEvent(name).then(() => {
- const href = get('href');
- if (href) {
- location.href = href;
- }
- });
- } else {
- trackEvent(name);
- }
- });
-
- element.addEventListener(event, listener, true);
- });
- };
-
- /* Handle history changes */
-
- const handlePush = (state, title, url) => {
- if (!url) return;
-
- observeTitle();
- currentRef = currentUrl;
- const newUrl = url.toString();
-
- if (newUrl.substring(0, 4) === 'http') {
- currentUrl = '/' + newUrl.split('/').splice(3).join('/');
- } else {
- currentUrl = newUrl;
- }
-
- if (currentUrl !== currentRef) {
- setTimeout(() => trackView(), delayDuration);
+ const track = (name = {}, data = {}) => {
+ if (typeof name === 'string') {
+ return send({ ...getPayload(), ...data, name });
+ } else if (typeof name === 'object') {
+ return send({ ...getPayload(), ...name });
}
+ return Promise.reject();
};
- const observeDocument = () => {
- const monitorMutate = mutations => {
- mutations.forEach(mutation => {
- const element = mutation.target;
- addEvent(element);
- addEvents(element);
- });
- };
-
- const observer = new MutationObserver(monitorMutate);
- observer.observe(document, { childList: true, subtree: true });
- };
-
- const observeTitle = () => {
- const monitorMutate = mutations => {
- currentPageTitle = mutations[0].target.text;
- };
-
- const observer = new MutationObserver(monitorMutate);
- observer.observe(document.querySelector('title'), {
- subtree: true,
- characterData: true,
- childList: true,
- });
- };
-
- /* Global */
-
- if (!window.umami) {
- const umami = eventValue => trackEvent(eventValue);
- umami.trackView = trackView;
- umami.trackEvent = trackEvent;
-
- window.umami = umami;
- }
-
/* Start */
+ if (!window.umami) {
+ window.umami = {
+ track,
+ };
+ }
+
+ let currentUrl = `${pathname}${search}`;
+ let currentRef = getPath(document.referrer);
+ let title = document.title;
+ let cache;
+ let initialized;
+
if (autoTrack && !trackingDisabled()) {
history.pushState = hook(history, 'pushState', handlePush);
history.replaceState = hook(history, 'replaceState', handlePush);
+ handleClick();
+ observeTitle();
- const update = () => {
- if (document.readyState === 'complete') {
- trackView();
-
- if (cssEvents) {
- addEvents(document);
- observeDocument();
- }
+ const init = () => {
+ if (document.readyState === 'complete' && !initialized) {
+ track();
+ initialized = true;
}
};
- document.addEventListener('readystatechange', update, true);
+ document.addEventListener('readystatechange', init, true);
- update();
+ init();
}
})(window);