From 1574915d68c9b9f37edf62a93d2bf197d0c6851f Mon Sep 17 00:00:00 2001
From: Felipe Barso <77860630+aprendendofelipe@users.noreply.github.com>
Date: Wed, 18 Dec 2024 08:23:22 -0300
Subject: [PATCH 1/2] feat(umami): add Umami Analytics Server via Docker for
Dev and Tests
---
.env | 6 ++
infra/docker-compose.development.yml | 17 ++++
infra/scripts/01-create-umami-database.sql | 1 +
infra/scripts/config-umami.js | 93 ++++++++++++++++++++++
4 files changed, 117 insertions(+)
create mode 100644 infra/scripts/01-create-umami-database.sql
create mode 100644 infra/scripts/config-umami.js
diff --git a/.env b/.env
index e233dd888..08bb6336e 100644
--- a/.env
+++ b/.env
@@ -16,3 +16,9 @@ EMAIL_HTTP_PORT=1080
EMAIL_USER=
EMAIL_PASSWORD=
UNDER_MAINTENANCE={"methodsAndPaths":["POST /api/v1/under-maintenance-test$"]}
+
+# Umami Analytics
+UMAMI_DB=umami
+UMAMI_PORT=3001
+UMAMI_ENDPOINT=http://localhost:$UMAMI_PORT
+NEXT_PUBLIC_UMAMI_WEBSITE_ID=0d54205e-06f1-49b0-89e2-1fc412e52a80
diff --git a/infra/docker-compose.development.yml b/infra/docker-compose.development.yml
index ee26d2b38..f7931d3e6 100644
--- a/infra/docker-compose.development.yml
+++ b/infra/docker-compose.development.yml
@@ -9,7 +9,13 @@ services:
- '${POSTGRES_PORT}:5432'
volumes:
- postgres_data:/data/postgres
+ - ./scripts:/docker-entrypoint-initdb.d
restart: unless-stopped
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
+ interval: 5s
+ timeout: 5s
+ retries: 5
mailcatcher:
container_name: mailcatcher
image: sj26/mailcatcher
@@ -19,5 +25,16 @@ services:
ports:
- '${EMAIL_SMTP_PORT}:1025'
- '${EMAIL_HTTP_PORT}:1080'
+ umami:
+ image: ghcr.io/umami-software/umami:postgresql-latest
+ ports:
+ - '${UMAMI_PORT}:3000'
+ environment:
+ DATABASE_URL: postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres_dev:5432/$UMAMI_DB
+ depends_on:
+ postgres_dev:
+ condition: service_healthy
+ init: true
+ restart: unless-stopped
volumes:
postgres_data:
diff --git a/infra/scripts/01-create-umami-database.sql b/infra/scripts/01-create-umami-database.sql
new file mode 100644
index 000000000..6c4990a2e
--- /dev/null
+++ b/infra/scripts/01-create-umami-database.sql
@@ -0,0 +1 @@
+CREATE DATABASE umami;
diff --git a/infra/scripts/config-umami.js b/infra/scripts/config-umami.js
new file mode 100644
index 000000000..15d361c8c
--- /dev/null
+++ b/infra/scripts/config-umami.js
@@ -0,0 +1,93 @@
+/* eslint-disable no-console */
+const { Client } = require('pg');
+
+const endpoint = process.env.UMAMI_ENDPOINT;
+const websiteDomain = `${process.env.NEXT_PUBLIC_WEBSERVER_HOST}:${process.env.NEXT_PUBLIC_WEBSERVER_PORT}`;
+const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
+const connectionString = `postgres://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}/${process.env.UMAMI_DB}`;
+const username = process.env.UMAMI_API_CLIENT_USERNAME || 'admin';
+const password = process.env.UMAMI_API_CLIENT_PASSWORD || 'umami';
+
+const client = new Client({
+ connectionString,
+ connectionTimeoutMillis: 5000,
+ idleTimeoutMillis: 30000,
+ allowExitOnIdle: false,
+});
+
+configUmami();
+
+async function configUmami() {
+ console.log('\n> Waiting for Umami Server to start...');
+ console.log('> Endpoint:', endpoint);
+
+ await waitForServer();
+
+ console.log('> Creating Umami configuration...');
+
+ const token = await fetch(`${endpoint}/api/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ username,
+ password,
+ }),
+ })
+ .then((res) => res.json())
+ .then((data) => data.token);
+
+ console.log('> Token:', token);
+
+ const websites = await fetch(`${endpoint}/api/websites`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ .then((res) => res.json())
+ .then((data) => data.data);
+
+ let existDevWebisite;
+
+ if (websites.length) {
+ existDevWebisite = websites.some((site) => site.id === websiteId);
+ }
+
+ await client.connect();
+
+ if (!existDevWebisite) {
+ const website = await fetch(`${endpoint}/api/websites`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ name: 'TabNews Dev',
+ domain: websiteDomain,
+ }),
+ }).then((res) => res.json());
+
+ await client.query('UPDATE website SET website_id = $1 WHERE website_id = $2;', [websiteId, website.id]);
+ }
+
+ await client.end();
+
+ console.log('> Umami configuration created!');
+}
+
+async function waitForServer(attempts = 5) {
+ try {
+ return await fetch(`${endpoint}/api/heartbeat`);
+ } catch (error) {
+ if (attempts > 1) {
+ console.log('> Umami is not ready, waiting...');
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ return waitForServer(attempts - 1);
+ }
+
+ console.error('🔴 Umami is not ready, exiting...');
+ process.exit(1);
+ }
+}
From 8235c6e0e216b0ce42f993acdb1bf0fb465b7344 Mon Sep 17 00:00:00 2001
From: Felipe Barso <77860630+aprendendofelipe@users.noreply.github.com>
Date: Wed, 18 Dec 2024 10:49:41 -0300
Subject: [PATCH 2/2] feat(analytics): add Umami tracker to `/login`,
`/cadastro` and `/publicar` pages
---
next.config.js | 4 +
pages/_app.public.js | 13 +-
pages/interface/components/Analytics/index.js | 26 ++
pages/interface/index.js | 1 +
public/analytics.js | 277 ++++++++++++++++++
5 files changed, 310 insertions(+), 11 deletions(-)
create mode 100644 pages/interface/components/Analytics/index.js
create mode 100644 public/analytics.js
diff --git a/next.config.js b/next.config.js
index 2fe5e5325..fb78be929 100644
--- a/next.config.js
+++ b/next.config.js
@@ -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() {
diff --git a/pages/_app.public.js b/pages/_app.public.js
index 0e825634a..254ddf2c2 100644
--- a/pages/_app.public.js
+++ b/pages/_app.public.js
@@ -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);
@@ -30,15 +29,7 @@ function MyApp({ Component, pageProps }) {
- {
- const { pathname } = new URL(event.url);
- if (['/', '/publicar'].includes(pathname)) {
- return null;
- }
- return event;
- }}
- />
+
);
diff --git a/pages/interface/components/Analytics/index.js b/pages/interface/components/Analytics/index.js
new file mode 100644
index 000000000..302fdb3c6
--- /dev/null
+++ b/pages/interface/components/Analytics/index.js
@@ -0,0 +1,26 @@
+import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
+import Script from 'next/script';
+
+export default function Analytics() {
+ return (
+ <>
+
+ {
+ const { pathname } = new URL(event.url);
+ if (['/', '/publicar'].includes(pathname)) {
+ return null;
+ }
+ return event;
+ }}
+ />
+ >
+ );
+}
diff --git a/pages/interface/index.js b/pages/interface/index.js
index 40a282a2d..9ff6b5050 100644
--- a/pages/interface/index.js
+++ b/pages/interface/index.js
@@ -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';
diff --git a/public/analytics.js b/public/analytics.js
new file mode 100644
index 000000000..f86a8641d
--- /dev/null
+++ b/public/analytics.js
@@ -0,0 +1,277 @@
+/* eslint-disable require-await */
+// 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);