aboutsummaryrefslogtreecommitdiff
path: root/src/tracker
diff options
context:
space:
mode:
Diffstat (limited to 'src/tracker')
-rw-r--r--src/tracker/index.d.ts153
-rw-r--r--src/tracker/index.js240
2 files changed, 393 insertions, 0 deletions
diff --git a/src/tracker/index.d.ts b/src/tracker/index.d.ts
new file mode 100644
index 0000000..32fbee9
--- /dev/null
+++ b/src/tracker/index.d.ts
@@ -0,0 +1,153 @@
+export type TrackedProperties = {
+ /**
+ * Hostname of server
+ *
+ * @description extracted from `window.location.hostname`
+ * @example 'analytics.umami.is'
+ */
+ hostname: string;
+
+ /**
+ * Browser language
+ *
+ * @description extracted from `window.navigator.language`
+ * @example 'en-US', 'fr-FR'
+ */
+ language: string;
+
+ /**
+ * Page referrer
+ *
+ * @description extracted from `window.navigator.language`
+ * @example 'https://analytics.umami.is/docs/getting-started'
+ */
+ referrer: string;
+
+ /**
+ * Screen dimensions
+ *
+ * @description extracted from `window.screen.width` and `window.screen.height`
+ * @example '1920x1080', '2560x1440'
+ */
+ screen: string;
+
+ /**
+ * Page title
+ *
+ * @description extracted from `document.querySelector('head > title')`
+ * @example 'umami'
+ */
+ title: string;
+
+ /**
+ * Page url
+ *
+ * @description built from `${window.location.pathname}${window.location.search}`
+ * @example 'docs/getting-started'
+ */
+ url: string;
+
+ /**
+ * Website ID (required)
+ *
+ * @example 'b59e9c65-ae32-47f1-8400-119fcf4861c4'
+ */
+ website: string;
+};
+
+export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
+
+/**
+ *
+ * Event Data can work with any JSON data. There are a few rules in place to maintain performance.
+ * - Numbers have a max precision of 4.
+ * - Strings have a max length of 500.
+ * - Arrays are converted to a String, with the same max length of 500.
+ * - Objects have a max of 50 properties. Arrays are considered 1 property.
+ */
+export interface EventData {
+ [key: string]: number | string | EventData | number[] | string[] | EventData[];
+}
+
+export type EventProperties = {
+ /**
+ * NOTE: event names will be truncated past 50 characters
+ */
+ name: string;
+ data?: EventData;
+} & WithRequired<TrackedProperties, 'website'>;
+export type PageViewProperties = WithRequired<TrackedProperties, 'website'>;
+export type CustomEventFunction = (
+ props: PageViewProperties,
+) => EventProperties | PageViewProperties;
+
+export type UmamiTracker = {
+ track: {
+ /**
+ * Track a page view
+ *
+ * @example ```
+ * umami.track();
+ * ```
+ */
+ (): Promise<string>;
+
+ /**
+ * Track an event with a given name
+ *
+ * NOTE: event names will be truncated past 50 characters
+ *
+ * @example ```
+ * umami.track('signup-button');
+ * ```
+ */
+ (eventName: string): Promise<string>;
+
+ /**
+ * Tracks an event with dynamic data.
+ *
+ * NOTE: event names will be truncated past 50 characters
+ *
+ * When tracking events, the default properties are included in the payload. This is equivalent to running:
+ *
+ * ```js
+ * umami.track(props => ({
+ * ...props,
+ * name: 'signup-button',
+ * data: {
+ * name: 'newsletter',
+ * id: 123
+ * }
+ * }));
+ * ```
+ *
+ * @example ```
+ * umami.track('signup-button', { name: 'newsletter', id: 123 });
+ * ```
+ */
+ (eventName: string, obj: EventData): Promise<string>;
+
+ /**
+ * Tracks a page view with custom properties
+ *
+ * @example ```
+ * umami.track({ website: 'e676c9b4-11e4-4ef1-a4d7-87001773e9f2', url: '/home', title: 'Home page' });
+ * ```
+ */
+ (properties: PageViewProperties): Promise<string>;
+
+ /**
+ * Tracks an event with fully customizable dynamic data
+ * If you don't specify any `name` and/or `data`, it will be treated as a page view
+ *
+ * @example ```
+ * umami.track((props) => ({ ...props, url: path }));
+ * ```
+ */
+ (eventFunction: CustomEventFunction): Promise<string>;
+ };
+};
+
+export interface Window {
+ umami: UmamiTracker;
+}
diff --git a/src/tracker/index.js b/src/tracker/index.js
new file mode 100644
index 0000000..ad3648a
--- /dev/null
+++ b/src/tracker/index.js
@@ -0,0 +1,240 @@
+(window => {
+ const {
+ screen: { width, height },
+ navigator: { language, doNotTrack: ndnt, msDoNotTrack: msdnt },
+ location,
+ document,
+ history,
+ top,
+ doNotTrack,
+ } = window;
+ const { currentScript, referrer } = document;
+ if (!currentScript) return;
+
+ const { hostname, href, origin } = location;
+ const localStorage = href.startsWith('data:') ? undefined : window.localStorage;
+
+ 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 beforeSend = attr(`${_data}before-send`);
+ const tag = attr(`${_data}tag`) || undefined;
+ const autoTrack = attr(`${_data}auto-track`) !== _false;
+ const dnt = attr(`${_data}do-not-track`) === _true;
+ const excludeSearch = attr(`${_data}exclude-search`) === _true;
+ const excludeHash = attr(`${_data}exclude-hash`) === _true;
+ const domain = attr(`${_data}domains`) || '';
+ const credentials = attr(`${_data}fetch-credentials`) || 'omit';
+
+ const domains = domain.split(',').map(n => n.trim());
+ const host =
+ hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/');
+ const endpoint = `${host.replace(/\/$/, '')}__COLLECT_API_ENDPOINT__`;
+ const screen = `${width}x${height}`;
+ const eventRegex = /data-umami-event-([\w-_]+)/;
+ const eventNameAttribute = `${_data}umami-event`;
+ const delayDuration = 300;
+
+ /* Helper functions */
+
+ const normalize = raw => {
+ if (!raw) return raw;
+ try {
+ const u = new URL(raw, location.href);
+ if (excludeSearch) u.search = '';
+ if (excludeHash) u.hash = '';
+ return u.toString();
+ } catch {
+ return raw;
+ }
+ };
+
+ const getPayload = () => ({
+ website,
+ screen,
+ language,
+ title: document.title,
+ hostname,
+ url: currentUrl,
+ referrer: currentRef,
+ tag,
+ id: identity ? identity : undefined,
+ });
+
+ const hasDoNotTrack = () => {
+ const dnt = doNotTrack || ndnt || msdnt;
+ return dnt === 1 || dnt === '1' || dnt === 'yes';
+ };
+
+ /* Event handlers */
+
+ const handlePush = (_state, _title, url) => {
+ if (!url) return;
+
+ currentRef = currentUrl;
+ currentUrl = normalize(new URL(url, location.href).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 handleClicks = () => {
+ const trackElement = async el => {
+ const eventName = el.getAttribute(eventNameAttribute);
+ if (eventName) {
+ const eventData = {};
+
+ el.getAttributeNames().forEach(name => {
+ const match = name.match(eventRegex);
+ if (match) eventData[match[1]] = el.getAttribute(name);
+ });
+
+ return track(eventName, eventData);
+ }
+ };
+ const onClick = async e => {
+ const el = e.target;
+ const parentElement = el.closest('a,button');
+ if (!parentElement) return trackElement(el);
+
+ const { href, target } = parentElement;
+ if (!parentElement.getAttribute(eventNameAttribute)) return;
+
+ if (parentElement.tagName === 'BUTTON') {
+ return trackElement(parentElement);
+ }
+ if (parentElement.tagName === 'A' && href) {
+ const external =
+ target === '_blank' ||
+ e.ctrlKey ||
+ e.shiftKey ||
+ e.metaKey ||
+ (e.button && e.button === 1);
+ if (!external) e.preventDefault();
+ return trackElement(parentElement).then(() => {
+ if (!external) {
+ (target === '_top' ? top.location : location).href = href;
+ }
+ });
+ }
+ };
+ document.addEventListener('click', onClick, true);
+ };
+
+ /* Tracking functions */
+
+ const trackingDisabled = () =>
+ disabled ||
+ !website ||
+ localStorage?.getItem('umami.disabled') ||
+ (domain && !domains.includes(hostname)) ||
+ (dnt && hasDoNotTrack());
+
+ const send = async (payload, type = 'event') => {
+ if (trackingDisabled()) return;
+
+ const callback = window[beforeSend];
+
+ if (typeof callback === 'function') {
+ payload = await Promise.resolve(callback(type, payload));
+ }
+
+ if (!payload) return;
+
+ try {
+ const res = await fetch(endpoint, {
+ keepalive: true,
+ method: 'POST',
+ body: JSON.stringify({ type, payload }),
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(typeof cache !== 'undefined' && { 'x-umami-cache': cache }),
+ },
+ credentials,
+ });
+
+ const data = await res.json();
+ if (data) {
+ disabled = !!data.disabled;
+ cache = data.cache;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ } catch (_e) {
+ /* no-op */
+ }
+ };
+
+ const init = () => {
+ if (!initialized) {
+ initialized = true;
+ track();
+ handlePathChanges();
+ handleClicks();
+ }
+ };
+
+ const track = (name, data) => {
+ if (typeof name === 'string') return send({ ...getPayload(), name, data });
+ if (typeof name === 'object') return send({ ...name });
+ if (typeof name === 'function') return send(name(getPayload()));
+ return send(getPayload());
+ };
+
+ const identify = (id, data) => {
+ if (typeof id === 'string') {
+ identity = id;
+ }
+
+ cache = '';
+ return send(
+ {
+ ...getPayload(),
+ data: typeof id === 'object' ? id : data,
+ },
+ 'identify',
+ );
+ };
+
+ /* Start */
+
+ if (!window.umami) {
+ window.umami = {
+ track,
+ identify,
+ };
+ }
+
+ let currentUrl = normalize(href);
+ let currentRef = normalize(referrer.startsWith(origin) ? '' : referrer);
+
+ let initialized = false;
+ let disabled = false;
+ let cache;
+ let identity;
+
+ if (autoTrack && !trackingDisabled()) {
+ if (document.readyState === 'complete') {
+ init();
+ } else {
+ document.addEventListener('readystatechange', init, true);
+ }
+ }
+})(window);