Skip to content

Commit

Permalink
feat(analytics): add Umami tracker to /login, /cadastro and `/pub…
Browse files Browse the repository at this point in the history
…licar` pages
  • Loading branch information
aprendendofelipe committed Dec 18, 2024
1 parent 1574915 commit 354287d
Show file tree
Hide file tree
Showing 5 changed files with 309 additions and 11 deletions.
4 changes: 4 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ module.exports = {
source: '/recentes/rss',
destination: '/api/v1/contents/rss',
},
{
source: '/api/v1/analytics',
destination: `${process.env.UMAMI_ENDPOINT}/api/send`,
},
];
},
headers() {
Expand Down
13 changes: 2 additions & 11 deletions pages/_app.public.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Analytics } from '@vercel/analytics/react';
import { RevalidateProvider } from 'next-swr';
import { SWRConfig } from 'swr';

import { ThemeProvider, Turnstile } from '@/TabNewsUI';
import { DefaultHead, UserProvider } from 'pages/interface';
import { Analytics, DefaultHead, UserProvider } from 'pages/interface';

async function SWRFetcher(resource, init) {
const response = await fetch(resource, init);
Expand All @@ -30,15 +29,7 @@ function MyApp({ Component, pageProps }) {
<Component {...pageProps} />
</RevalidateProvider>
</SWRConfig>
<Analytics
beforeSend={(event) => {
const { pathname } = new URL(event.url);
if (['/', '/publicar'].includes(pathname)) {
return null;
}
return event;
}}
/>
<Analytics />
</UserProvider>
</ThemeProvider>
);
Expand Down
26 changes: 26 additions & 0 deletions pages/interface/components/Analytics/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
import Script from 'next/script';

export default function Analytics() {
return (
<>
<Script
id="umami-script"
src="/analytics.js"
data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
data-path-matcher="^(/login|/cadastro|/publicar)$"
data-exclude-search="true"
strategy="lazyOnload"
/>
<VercelAnalytics
beforeSend={(event) => {
const { pathname } = new URL(event.url);
if (['/', '/publicar'].includes(pathname)) {
return null;
}
return event;
}}
/>
</>
);
}
1 change: 1 addition & 0 deletions pages/interface/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as Analytics } from './components/Analytics';
export { DefaultHead, default as Head } from './components/Head';
export { default as useCollapse } from './hooks/useCollapse';
export { default as useMediaQuery } from './hooks/useMediaQuery';
Expand Down
276 changes: 276 additions & 0 deletions public/analytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
// Original code from:
// https://github.com/umami-software/umami/blob/bce70c1034c6668255261c26093a35900b869566/src/tracker/index.js
// With a modification by @aprendendofelipe to filter tracked paths

((window) => {
const {
screen: { width, height },
navigator: { language },
location,
document,
history,
} = window;
const { hostname, href, origin } = location;
const { currentScript, referrer } = document;
const localStorage = href.startsWith('data:') ? undefined : window.localStorage;

if (!currentScript) return;

const _data = 'data-';
const _false = 'false';
const _true = 'true';
const attr = currentScript.getAttribute.bind(currentScript);
const website = attr(_data + 'website-id');
const hostUrl = attr(_data + 'host-url');
const tag = attr(_data + 'tag');
const autoTrack = attr(_data + 'auto-track') !== _false;
const excludeSearch = attr(_data + 'exclude-search') === _true;
const pathMatcher = attr(_data + 'path-matcher');
const pathRegex = pathMatcher ? new RegExp(pathMatcher) : undefined;
const domain = attr(_data + 'domains') || '';
const domains = domain.split(',').map((n) => n.trim());
const host = hostUrl || currentScript.src.split('/').slice(0, -1).join('/');
const endpoint = `${host.replace(/\/$/, '')}/api/v1/analytics`;
const screen = `${width}x${height}`;
const eventRegex = /data-umami-event-([\w-_]+)/;
const eventNameAttribute = _data + 'umami-event';
const delayDuration = 300;

/* Helper functions */

const encode = (str) => {
if (!str) {
return undefined;
}

try {
const result = decodeURI(str);

if (result !== str) {
return result;
}
} catch (e) {
return str;
}

return encodeURI(str);
};

const parseURL = (url) => {
try {
// use location.origin as the base to handle cases where the url is a relative path
const { pathname, search, hash } = new URL(url, location.href);
url = pathname + search + hash;
} catch (e) {
/* empty */
}
return excludeSearch ? url.split('?')[0] : url;
};

const getPayload = () => ({
website,
hostname,
screen,
language,
title: encode(title),
url: encode(currentUrl),
referrer: encode(currentRef),
tag: tag ? tag : undefined,
});

/* Event handlers */

const handlePush = (state, title, url) => {
if (!url) return;

currentRef = currentUrl;
currentUrl = parseURL(url.toString());

if (currentUrl !== currentRef) {
setTimeout(track, delayDuration);
}
};

const handlePathChanges = () => {
const hook = (_this, method, callback) => {
const orig = _this[method];

return (...args) => {
callback.apply(null, args);

return orig.apply(_this, args);
};
};

history.pushState = hook(history, 'pushState', handlePush);
history.replaceState = hook(history, 'replaceState', handlePush);
};

const handleTitleChanges = () => {
const observer = new MutationObserver(([entry]) => {
title = entry && entry.target ? entry.target.text : undefined;
});

const node = document.querySelector('head > title');

if (node) {
observer.observe(node, {
subtree: true,
characterData: true,
childList: true,
});
}
};

const handleClicks = () => {
document.addEventListener(
'click',
async (e) => {
const isSpecialTag = (tagName) => ['BUTTON', 'A'].includes(tagName);

const trackElement = async (el) => {
const attr = el.getAttribute.bind(el);
const eventName = attr(eventNameAttribute);

if (eventName) {
const eventData = {};

el.getAttributeNames().forEach((name) => {
const match = name.match(eventRegex);

if (match) {
eventData[match[1]] = attr(name);
}
});

return track(eventName, eventData);
}
};

const findParentTag = (rootElem, maxSearchDepth) => {
let currentElement = rootElem;
for (let i = 0; i < maxSearchDepth; i++) {
if (isSpecialTag(currentElement.tagName)) {
return currentElement;
}
currentElement = currentElement.parentElement;
if (!currentElement) {
return null;
}
}
};

const el = e.target;
const parentElement = isSpecialTag(el.tagName) ? el : findParentTag(el, 10);

if (parentElement) {
const { href, target } = parentElement;
const eventName = parentElement.getAttribute(eventNameAttribute);

if (eventName) {
if (parentElement.tagName === 'A') {
const external =
target === '_blank' || e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1);

if (eventName && href) {
if (!external) {
e.preventDefault();
}
return trackElement(parentElement).then(() => {
if (!external) location.href = href;
});
}
} else if (parentElement.tagName === 'BUTTON') {
return trackElement(parentElement);
}
}
} else {
return trackElement(el);
}
},
true,
);
};

/* Tracking functions */

const trackingDisabled = () =>
!website || (localStorage && localStorage.getItem('umami.disabled')) || (domain && !domains.includes(hostname));

const send = async (payload, type = 'event') => {
if (trackingDisabled()) return;

if (pathRegex && !pathRegex.test(payload.url)) return;

const headers = {
'Content-Type': 'application/json',
};

if (typeof cache !== 'undefined') {
headers['x-umami-cache'] = cache;
}

try {
const res = await fetch(endpoint, {
method: 'POST',
body: JSON.stringify({ type, payload }),
headers,
});
const text = await res.text();

return (cache = text);
} catch (e) {
/* empty */
}
};

const init = () => {
if (!initialized) {
track();
handlePathChanges();
handleTitleChanges();
handleClicks();
initialized = true;
}
};

const track = (obj, data) => {
if (typeof obj === 'string') {
return send({
...getPayload(),
name: obj,
data: typeof data === 'object' ? data : undefined,
});
} else if (typeof obj === 'object') {
return send(obj);
} else if (typeof obj === 'function') {
return send(obj(getPayload()));
}
return send(getPayload());
};

const identify = (data) => send({ ...getPayload(), data }, 'identify');

/* Start */

if (!window.umami) {
window.umami = {
track,
identify,
};
}

let currentUrl = parseURL(href);
let currentRef = referrer.startsWith(origin) ? '' : referrer;
let title = document.title;
let cache;
let initialized;

if (autoTrack && !trackingDisabled()) {
if (document.readyState === 'complete') {
init();
} else {
document.addEventListener('readystatechange', init, true);
}
}
})(window);

0 comments on commit 354287d

Please sign in to comment.