we Merge branch 'dev' into feat/um-209-implement-reset-date

This commit is contained in:
Francis Cao 2023-03-27 15:51:54 -07:00
commit 17c8cc07e4
11 changed files with 234 additions and 325 deletions

View File

@ -36,7 +36,7 @@
["store", "./store"],
["styles", "./styles"]
],
"extensions": [".ts", ".js", ".jsx", ".json"]
"extensions": [".ts", ".tsx", ".js", ".jsx", ".json"]
}
}
},

View File

@ -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;
}

View File

@ -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(() => {

View File

@ -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 <Loading icon="dots" />;
}
if (!data) {
return null;
}
return (
<BarChart
className={className}
@ -81,7 +71,6 @@ export default function EventsChart({ websiteId, className, token }) {
unit={unit}
height={300}
records={getDateLength(startDate, endDate, unit)}
onUpdate={handleUpdate}
loading={isLoading}
stacked
/>

View File

@ -38,22 +38,10 @@ 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 = [
return [
{
label: formatMessage(labels.uniqueVisitors),
data: data.sessions,
@ -67,6 +55,7 @@ export default function PageviewsChart({
...colors.views,
},
];
}, [data]);
return (
<div ref={ref}>
@ -78,7 +67,6 @@ export default function PageviewsChart({
unit={unit}
records={records}
animationDuration={visible ? animationDuration : 0}
onUpdate={handleUpdate}
loading={loading}
/>
</div>

View File

@ -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,10 +24,10 @@ 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', {
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,
@ -40,6 +40,7 @@ export default function TestConsole() {
},
},
array: [1, 2, 3],
},
});
}
@ -52,22 +53,17 @@ export default function TestConsole() {
return (
<Page loading={isLoading} error={error}>
<Head>
{typeof window !== 'undefined' && website && (
<script
async
defer
data-website-id={website.id}
src={`${basePath}/script.js`}
data-cache="true"
/>
)}
</Head>
<PageHeader title="Test console">
<WebsiteSelect websiteId={website?.id} onSelect={handleChange} />
</PageHeader>
{website && (
<>
<Script
async
data-website-id={website.id}
src={`${basePath}/script.js`}
data-cache="true"
/>
<Row className={styles.test}>
<Column xs="4">
<div className={styles.header}>Page links</div>
@ -78,14 +74,14 @@ export default function TestConsole() {
<Link href={`/console/${websiteId}?page=2`}>page two</Link>
</div>
<div>
<a href="https://www.google.com" className="umami--click--external-link-direct">
<a href="https://www.google.com" data-umami-event="external-link-direct">
external link (direct)
</a>
</div>
<div>
<a
href="https://www.google.com"
className="umami--click--external-link-tab"
data-umami-event="external-link-tab"
target="_blank"
rel="noreferrer"
>
@ -94,10 +90,20 @@ export default function TestConsole() {
</div>
</Column>
<Column xs="4">
<div className={styles.header}>CSS events</div>
<Button id="primary-button" className="umami--click--button-click" variant="action">
<div className={styles.header}>Click events</div>
<Button id="send-event-button" data-umami-event="button-click" variant="action">
Send event
</Button>
<p />
<Button
id="send-event-data-button"
data-umami-event="button-click"
data-umami-event-name="bob"
data-umami-event-id="123"
variant="action"
>
Send event with data
</Button>
</Column>
<Column xs="4">
<div className={styles.header}>Javascript events</div>
@ -108,14 +114,12 @@ export default function TestConsole() {
</Row>
<Row>
<Column>
<div className={styles.header}>Statistics</div>
<WebsiteChart
websiteId={website.id}
title={website.name}
domain={website.domain}
showLink
/>
<div className={styles.header}>Events</div>
<EventsChart websiteId={website.id} />
</Column>
</Row>

View File

@ -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;
}

View File

@ -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,
await saveEvent({
urlPath,
urlQuery,
referrerPath,
referrerQuery,
referrerDomain,
pageTitle,
});
} else if (type === 'event') {
await saveEvent({
...session,
urlPath,
urlQuery,
pageTitle,
eventName,
eventData,
...session,
sessionId: session.id,
});
} else {
return badRequest(res);
}
const token = createToken(session, secret());

View File

@ -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');

View File

@ -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';

View File

@ -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);
}
};
const handleClick = () => {
const callback = e => {
const t = e.target;
const attr = t.getAttribute.bind(t);
const eventName = attr(_data + 'umami-event');
if (eventName) {
const eventData = {};
t.getAttributeNames().forEach(name => {
const match = name.match(eventRegex);
if (match) {
eventData[match[1]] = attr(name);
}
/* Collect metrics */
const getPayload = () => ({
website,
hostname,
screen,
language,
url: currentUrl,
});
const collect = (type, payload) => {
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;
const track = (name = {}, data = {}) => {
if (typeof name === 'string') {
return send({ ...getPayload(), ...data, name });
} else if (typeof name === 'object') {
return send({ ...getPayload(), ...name });
}
});
} else {
trackEvent(name);
}
});
element.addEventListener(event, listener, true);
});
return Promise.reject();
};
/* 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 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);