diff --git a/.gitignore b/.gitignore index ca0f3c4f..8b349dbb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # production /build /public/umami.js +/public/snippet.js /lang-compiled /lang-formatted diff --git a/components/forms/TrackingCodeForm.js b/components/forms/TrackingCodeForm.js index a98b471d..61df1275 100644 --- a/components/forms/TrackingCodeForm.js +++ b/components/forms/TrackingCodeForm.js @@ -27,9 +27,9 @@ export default function TrackingCodeForm({ values, onClose }) { /> - + diff --git a/package.json b/package.json index 4cb3b858..93d23142 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "start": "next start", "build-app": "next build", "build-tracker": "rollup -c rollup.tracker.config.js", + "build-snippet": "rollup -c rollup.snippet.config.js", "copy-db-schema": "node scripts/copy-db-schema.js", "build-db-schema": "dotenv prisma introspect", "build-db-client": "dotenv prisma generate", diff --git a/rollup.snippet.config.js b/rollup.snippet.config.js new file mode 100644 index 00000000..c5925988 --- /dev/null +++ b/rollup.snippet.config.js @@ -0,0 +1,19 @@ +import 'dotenv/config'; +import buble from '@rollup/plugin-buble'; +import replace from '@rollup/plugin-replace'; +import resolve from '@rollup/plugin-node-resolve'; +import { terser } from 'rollup-plugin-terser'; + +export default { + input: 'tracker/snippet.js', + output: { + file: 'public/snippet.js', + format: 'iife', + }, + plugins: [ + replace({ __DNT__: !!process.env.ENABLE_DNT }), + resolve(), + buble(), + terser({ compress: { evaluate: false } }), + ], +}; diff --git a/tracker/index.js b/tracker/index.js index f7f00927..28b8dfca 100644 --- a/tracker/index.js +++ b/tracker/index.js @@ -1,6 +1,6 @@ import 'promise-polyfill/src/polyfill'; import 'unfetch/polyfill'; -import { post, hook, doNotTrack } from '../lib/web'; +import { doNotTrack, hook, post } from '../lib/web'; import { removeTrailingSlash } from '../lib/url'; (window => { @@ -19,6 +19,7 @@ import { removeTrailingSlash } from '../lib/url'; const website = script.getAttribute('data-website-id'); const hostUrl = script.getAttribute('data-host-url'); + const skipAuto = script.getAttribute('data-skip-auto'); const root = hostUrl ? removeTrailingSlash(hostUrl) : new URL(script.src).href.split('/').slice(0, -1).join('/'); @@ -28,13 +29,31 @@ import { removeTrailingSlash } from '../lib/url'; let currentUrl = `${pathname}${search}`; let currentRef = document.referrer; - /* Collect metrics */ + /* Handle events */ - const collect = (type, params) => { + const removeEvents = () => { + listeners.forEach(([element, type, listener]) => { + element && element.removeEventListener(type, listener, true); + }); + listeners.length = 0; + }; + + const loadEvents = () => { + document.querySelectorAll('[class*=\'umami--\']').forEach(element => { + element.className.split(' ').forEach(className => { + if (/^umami--([a-z]+)--([a-z0-9_]+[a-z0-9-_]+)$/.test(className)) { + const [, type, value] = className.split('--'); + const listener = () => collectEvent(type, value); + + listeners.push([element, type, listener]); + element.addEventListener(type, listener, true); + } + }); + }); + }; + const collect = (type, params, uuid) => { const payload = { - url: currentUrl, - referrer: currentRef, - website, + website: uuid, hostname, screen, language, @@ -51,13 +70,15 @@ import { removeTrailingSlash } from '../lib/url'; payload, }); }; + const pageView = (url = currentUrl, referrer = currentRef, uuid = website) => collect('pageview', { + url, + referrer, + }, uuid); - const pageView = () => collect('pageview').then(() => setTimeout(loadEvents, 300)); - - const pageEvent = (event_type, event_value) => collect('event', { event_type, event_value }); + /* Collect metrics */ + const pageViewWithAutoEvents = (url, referrer) => pageView(url, referrer).then(() => setTimeout(loadEvents, 300)); /* Handle history */ - const handlePush = (state, title, url) => { removeEvents(); currentRef = currentUrl; @@ -70,40 +91,38 @@ import { removeTrailingSlash } from '../lib/url'; currentUrl = newUrl; } - pageView(); + pageViewWithAutoEvents(currentUrl, currentRef); }; - history.pushState = hook(history, 'pushState', handlePush); - history.replaceState = hook(history, 'replaceState', handlePush); + const collectEvent = (event_type, event_value, url = currentUrl, uuid = website) => collect('event', { + url, + event_type, + event_value, + }, uuid); - /* Handle events */ + const registerAutoEvents = () => { + history.pushState = hook(history, 'pushState', handlePush); + history.replaceState = hook(history, 'replaceState', handlePush); + return pageViewWithAutoEvents(currentUrl, currentRef); + }; - const removeEvents = () => { - listeners.forEach(([element, type, listener]) => { - element && element.removeEventListener(type, listener, true); + + const umamiFunctions = { collect, pageView, collectEvent, registerAutoEvents }; + const scheduledCalls = window.umami.calls; + + window.umami = event_value => collect('event', { event_type: 'custom', event_value }); + Object.keys(umamiFunctions).forEach((key) => { + window.umami[key] = umamiFunctions[key]; + }); + + if (scheduledCalls) { + scheduledCalls.forEach(([fnName, ...params]) => { + window.umami[fnName].apply(window.umami, params); }); - listeners.length = 0; - }; - - const loadEvents = () => { - document.querySelectorAll("[class*='umami--']").forEach(element => { - element.className.split(' ').forEach(className => { - if (/^umami--([a-z]+)--([a-z0-9_]+[a-z0-9-_]+)$/.test(className)) { - const [, type, value] = className.split('--'); - const listener = () => pageEvent(type, value); - - listeners.push([element, type, listener]); - element.addEventListener(type, listener, true); - } - }); - }); - }; + } /* Start */ - - pageView(); - - if (!window.umami) { - window.umami = event_value => collect('event', { event_type: 'custom', event_value }); + if (!skipAuto) { + registerAutoEvents().catch(e => console.error(e)); } })(window); diff --git a/tracker/snippet.js b/tracker/snippet.js new file mode 100644 index 00000000..df1a8eb5 --- /dev/null +++ b/tracker/snippet.js @@ -0,0 +1,43 @@ +(window => { + const umami = window.umami = window.umami || []; + if (!umami.registerAutoEvents) { + if (umami.invoked) { + window.console && console.error && console.error('Umami snippet included twice.'); + } else { + umami.invoked = true; + umami.calls = []; + umami.methods = ['registerAutoEvents', 'event', 'pageView']; + umami.factory = t => { + return function() { + const e = Array.prototype.slice.call(arguments); + e.unshift(t); + umami.calls.push(e); + return umami; + }; + }; + for (let t = 0; t < umami.methods.length; t++) { + let e = umami.methods[t]; + umami[e] = umami.factory(e); + } + umami.load = function(umamiScript, umamiUUID, skipAuto) { + const scriptElement = document.createElement('script'); + scriptElement.type = 'text/javascript'; + scriptElement.defer = true; + scriptElement.async = true; + scriptElement.setAttribute('data-website-id', umamiUUID); + if (skipAuto) { + scriptElement.setAttribute('data-skip-auto', 'true'); + } + scriptElement.src = umamiScript; + const otherScript = document.getElementsByTagName('script')[0]; + otherScript.parentNode.insertBefore(scriptElement, otherScript); + }; + + umami.load('[HOST]/umami.js', '[UMAMI_UUID]', false); + } + } +})(window); +// This snippet is for more advanced use case of Umami. If you want to track custom events, +// and not worry about having blocking script in the header, +// use this snippet (compiled version available in /public/snippet.js). +// Just remember to replace [HOST] and [UMAMI_UUID] when pasting it. \ No newline at end of file