/** Public types for the browser tracker. */ export type TrackedProperties = { /** * Hostname of server * * @description extracted from `window.location.hostname` * @example 'analytics.umami.is' */ hostname?: string; /** Distinct ID associated with the current visitor. */ id?: string; /** * Browser language * * @description extracted from `window.navigator.language` * @example 'en-US', 'fr-FR' */ language?: string; /** * Page referrer * * @description extracted from `document.referrer` * @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; /** Tag configured on the tracker script. */ tag?: string; /** * Page title * * @description extracted from `document.querySelector('head > title')` * @example 'umami' */ title?: string; /** * Page url * * @description normalized from `window.location.href` * @example 'https://analytics.umami.is/docs/getting-started' */ url?: string; /** * Website ID (required) * * @example 'b59e9c65-ae32-47f1-8400-119fcf4861c4' */ website: string; }; export type WithRequired = T & { [P in K]-?: T[P] }; export type EventDataValue = boolean | number | string | null | EventData | EventDataValue[]; /** * * 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]: EventDataValue; } export type EventProperties = { /** * NOTE: event names will be truncated past 50 characters */ name: string; data?: EventData; } & WithRequired; export type PageViewProperties = WithRequired; export type CustomEventFunction = ( props: PageViewProperties, ) => EventProperties | PageViewProperties; export type UmamiTracker = { track: { /** * Track a page view * * @example ``` * umami.track(); * ``` */ (): Promise; /** * Track an event with a given name * * NOTE: event names will be truncated past 50 characters * * @example ``` * umami.track('signup-button'); * ``` */ (eventName: string): Promise; /** * 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; /** * Tracks a page view with custom properties * * @example ``` * umami.track({ website: 'e676c9b4-11e4-4ef1-a4d7-87001773e9f2', url: '/home', title: 'Home page' }); * ``` */ (properties: PageViewProperties): Promise; /** * 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; }; identify: { /** * Identify a visitor with optional associated data. * * @example ``` * umami.identify('user-123', { plan: 'pro' }); * ``` */ (id: string, data?: EventData): Promise; /** * Associate data with the current visitor. An `id` string sets the Distinct ID. * * @example ``` * umami.identify({ id: 'user-123', plan: 'pro' }); * ``` */ (data: EventData & { id?: string }): Promise; }; getSession: () => { cache: string | undefined; website: string | null; }; }; declare global { interface Window { umami: UmamiTracker; } } type Payload = Record; type BeforeSend = ( type: string, payload: Payload, ) => Payload | null | undefined | Promise; type TrackerWindow = Window & typeof globalThis & { doNotTrack?: string | number | null; navigator: Navigator & { msDoNotTrack?: string | number | null; }; }; type TrackerDocument = Document & { currentScript: HTMLScriptElement | null; }; type MetricEntry = PerformanceEntry & { activationStart: number; duration: number; hadRecentInput: boolean; interactionId: number; responseStart: number; startTime: number; value: number; }; (window => { const { screen: { width, height }, navigator: { language, doNotTrack: ndnt, msDoNotTrack: msdnt }, location, document, history, top, doNotTrack, } = window; const { currentScript, referrer } = document as TrackerDocument; if (!currentScript) return; const { hostname, href, origin } = location; let localStorage: Storage | undefined; try { localStorage = href.startsWith('data:') ? undefined : window.localStorage; } catch { /* (DOMException) SecurityError: Access is denied for this document. */ } const _data = 'data-'; const _false = 'false'; const _true = 'true'; const attr = currentScript.getAttribute.bind(currentScript); const config = (value: string) => attr(`${_data}${value}`); const website = config('website-id'); const hostUrl = config('host-url'); const beforeSend = config('before-send'); const distinctId = config('distinct-id') || undefined; const tag = config('tag') || undefined; const autoTrack = config('auto-track') !== _false; const dnt = config('do-not-track') === _true; const excludeSearch = config('exclude-search') === _true; const excludeHash = config('exclude-hash') === _true; const domain = config('domains') || ''; const credentials = (config('fetch-credentials') || 'omit') as RequestCredentials; const perf = config('performance') === _true; const autoPageview = config('auto-pageview') !== _false; 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: string | URL): string => { if (!raw) return raw as string; try { const u = new URL(raw, location.href); if (excludeSearch) u.search = ''; if (excludeHash) u.hash = ''; return u.toString(); } catch { return raw as string; } }; // Strip the origin from same-origin referrers so the referrer domain // is never saved when it matches the current hostname const stripOrigin = (url: string): string => url === origin || url?.startsWith(origin + '/') ? url.slice(origin.length) : url; const getPayload = () => ({ website, screen, language, title: document.title, hostname, url: currentUrl, referrer: stripOrigin(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: unknown, _title: string, url?: string | URL | null) => { if (!url) return; if (typeof flushPerformance === 'function') { flushPerformance(); } currentRef = currentUrl; currentUrl = normalize(url); if (currentUrl !== currentRef && autoPageview) { setTimeout(track, delayDuration); } }; const handlePathChanges = () => { const hook = ( _this: History, method: 'pushState' | 'replaceState', callback: typeof handlePush, ) => { const orig = _this[method]; return (...args: Parameters) => { const result = orig.apply(_this, args); callback.apply(null, args); return result; }; }; history.pushState = hook(history, 'pushState', handlePush); history.replaceState = hook(history, 'replaceState', handlePush); }; const handleClicks = () => { const trackElement = async (el: Element) => { const eventName = el.getAttribute(eventNameAttribute); if (eventName) { const eventData: EventData = {}; el.getAttributeNames().forEach(name => { const match = name.match(eventRegex); if (match) eventData[match[1]] = el.getAttribute(name) as string; }); return track(eventName, eventData); } }; const onClick = (e: MouseEvent) => { const el = e.target as Element; const eventEl = el.closest(`[${eventNameAttribute}]`); if (!eventEl) return; if (eventEl.tagName === 'A' && (eventEl as HTMLAnchorElement).href) { const { href, target } = eventEl as HTMLAnchorElement; const external = target === '_blank' || e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1); if (!external) e.preventDefault(); return trackElement(eventEl).finally(() => { if (!external) { (target === '_top' ? (top as WindowProxy).location : location).href = href; } }); } return trackElement(eventEl); }; 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: Payload | null | undefined, type = 'event'): Promise => { if (trackingDisabled()) return; const callback = (window as unknown as Record)[beforeSend as string] as | BeforeSend | undefined; if (typeof callback === 'function') { payload = await Promise.resolve(callback(type, payload as Payload)); } if (!payload) return; try { const res = await fetch(endpoint, { keepalive: true, method: 'POST', body: JSON.stringify({ type, payload }), headers: { 'Content-Type': 'application/json', 'x-umami-website-id': website as string, 'x-umami-hostname': hostname, ...(typeof cache !== 'undefined' && { 'x-umami-cache': cache }), }, credentials, }); const data = (await res.json()) as { cache?: string; disabled?: boolean } | null; 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; if (autoPageview) track(); handlePathChanges(); handleClicks(); if (perf) initPerformance(); } }; const track = ( name?: string | Payload | ((payload: Payload) => Payload), data?: EventData, ): Promise => { 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: string | (EventData & { id?: string }), data?: EventData, ): Promise => { const nextIdentity = typeof id === 'string' ? id : id.id; if (nextIdentity !== undefined) { identity = nextIdentity; } cache = ''; return send( { ...getPayload(), data: typeof id === 'object' ? id : data, }, 'identify', ); }; /* Performance */ const initPerformance = () => { const metrics: Record = {}; let sent = false; let timeoutId: ReturnType | undefined; let isInitialLoad = true; let activationStart = 0; let pageStartTime = 0; const observe = (type: string, callback: (entry: MetricEntry) => void) => { try { const observer = new PerformanceObserver(list => { (list.getEntries() as MetricEntry[]).forEach(callback); }); observer.observe({ type, buffered: true }); } catch { /* not supported */ } }; // TTFB observe('navigation', entry => { activationStart = entry.activationStart || 0; metrics.ttfb = Math.max(entry.responseStart - activationStart, 0); }); // FCP observe('paint', entry => { if (entry.name === 'first-contentful-paint') { metrics.fcp = Math.max(entry.startTime - activationStart, 0); } }); // LCP observe('largest-contentful-paint', entry => { metrics.lcp = Math.max(entry.startTime - activationStart, 0); }); // CLS - session windows algorithm (gap < 1s, max 5s duration; report worst window) let clsSessionValue = 0; let clsSessionEntries: MetricEntry[] = []; observe('layout-shift', entry => { if (!entry.hadRecentInput) { const lastEntry = clsSessionEntries[clsSessionEntries.length - 1]; const firstEntry = clsSessionEntries[0]; if ( lastEntry && entry.startTime - lastEntry.startTime - lastEntry.duration < 1000 && entry.startTime - firstEntry.startTime < 5000 ) { clsSessionValue += entry.value; clsSessionEntries.push(entry); } else { clsSessionValue = entry.value; clsSessionEntries = [entry]; } if (clsSessionValue > (metrics.cls || 0)) { metrics.cls = clsSessionValue; } } }); // INP - group by interactionId, 98th percentile, 40ms threshold let interactions: Record = {}; let inpObserver: PerformanceObserver | undefined; const recordInteractions = (entries: PerformanceEntryList) => { (entries as MetricEntry[]).forEach(entry => { if (entry.interactionId) { const existing = interactions[entry.interactionId]; if (!existing || entry.duration > existing) { interactions[entry.interactionId] = entry.duration; } } }); }; try { inpObserver = new PerformanceObserver(list => recordInteractions(list.getEntries())); inpObserver.observe({ type: 'event', buffered: true, durationThreshold: 40, } as PerformanceObserverInit); } catch { /* not supported */ } const computeInp = () => { if (inpObserver) recordInteractions(inpObserver.takeRecords()); const values = Object.values(interactions).sort((a, b) => b - a); if (values.length) { const p98Index = Math.floor(Math.max(values.length, 10) * 0.02); metrics.inp = values[Math.min(p98Index, values.length - 1)]; } }; const getEntriesByType = (type: string): MetricEntry[] => { try { return (window.performance?.getEntriesByType?.(type) as MetricEntry[]) || []; } catch { return []; } }; const applyFallbackMetrics = () => { if (!isInitialLoad) return; if (metrics.ttfb === undefined) { const navigation = getEntriesByType('navigation')?.[0]; if (navigation) { metrics.ttfb = Math.max(navigation.responseStart - (navigation.activationStart || 0), 0); } } if (metrics.fcp === undefined) { const fcpEntry = getEntriesByType('paint')?.find( entry => entry.name === 'first-contentful-paint', ); if (fcpEntry) { metrics.fcp = Math.max(fcpEntry.startTime - activationStart, 0); } } if (metrics.lcp === undefined) { const lcpEntries = getEntriesByType('largest-contentful-paint'); const lcpEntry = lcpEntries?.[lcpEntries.length - 1]; if (lcpEntry) { metrics.lcp = Math.max(lcpEntry.startTime - activationStart, 0); } } }; const sendPerformance = () => { if (sent) return; applyFallbackMetrics(); computeInp(); metrics.duration = Math.round(performance.now() - pageStartTime); sent = true; if (timeoutId) clearTimeout(timeoutId); send({ ...getPayload(), ...metrics }, 'performance'); }; flushPerformance = () => { sendPerformance(); isInitialLoad = false; Object.keys(metrics).forEach(k => { delete metrics[k]; }); activationStart = 0; pageStartTime = performance.now(); clsSessionValue = 0; clsSessionEntries = []; interactions = {}; sent = false; if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(sendPerformance, 10000); }; timeoutId = setTimeout(sendPerformance, 10000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') sendPerformance(); }); window.addEventListener('pagehide', sendPerformance); }; /* Start */ if (!window.umami) { window.umami = { track, identify, getSession: () => ({ cache, website }), } as UmamiTracker; } let currentUrl = normalize(href); let currentRef = normalize(referrer); let initialized = false; let disabled = false; let cache: string | undefined; let identity = distinctId; let flushPerformance: (() => void) | undefined; if (distinctId) { void identify(distinctId); } if (autoTrack && !trackingDisabled()) { if (document.readyState === 'complete') { init(); } else { document.addEventListener('readystatechange', init, true); } } })(window as TrackerWindow);