From 5911ddaa93eebee4560e98eaaf3efdf12e6f3b75 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sun, 19 Jul 2026 22:04:49 -0700 Subject: [PATCH] Convert tracker build to TypeScript and add type checks --- package.json | 5 +- rollup.tracker.config.js | 4 +- src/tracker/index.d.ts | 72 +++---- src/tracker/{index.js => index.ts} | 318 +++++++++++++++++++++++++---- tsconfig.tracker.json | 17 ++ tsconfig.tracker.types.json | 13 ++ 6 files changed, 347 insertions(+), 82 deletions(-) rename src/tracker/{index.js => index.ts} (55%) create mode 100644 tsconfig.tracker.json create mode 100644 tsconfig.tracker.types.json diff --git a/package.json b/package.json index 0d701db8b..96aa7e033 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,10 @@ "build-app": "next build --turbo", "build-icons": "svgr ./src/assets --out-dir src/components/svg --typescript", "build-components": "node scripts/bump-components.js && tsup", - "build-tracker": "rollup -c rollup.tracker.config.js", + "build-tracker": "npm-run-all check-tracker build-tracker-script build-tracker-types", + "build-tracker-script": "rollup -c rollup.tracker.config.js", + "build-tracker-types": "tsc -p tsconfig.tracker.types.json && biome format --write src/tracker/index.d.ts", + "check-tracker": "tsc -p tsconfig.tracker.json --noEmit", "build-recorder": "rollup -c rollup.recorder.config.js", "build-prisma-client": "node scripts/build-prisma-client.js", "build-lang": "npm-run-all download-country-names download-language-names", diff --git a/rollup.tracker.config.js b/rollup.tracker.config.js index 202db36c9..8d8d277cf 100644 --- a/rollup.tracker.config.js +++ b/rollup.tracker.config.js @@ -1,14 +1,16 @@ import 'dotenv/config'; import replace from '@rollup/plugin-replace'; import terser from '@rollup/plugin-terser'; +import typescript from '@rollup/plugin-typescript'; export default { - input: 'src/tracker/index.js', + input: 'src/tracker/index.ts', output: { file: 'public/script.js', format: 'iife', }, plugins: [ + typescript({ tsconfig: './tsconfig.tracker.json' }), replace({ __COLLECT_API_HOST__: process.env.COLLECT_API_HOST || '', __COLLECT_API_ENDPOINT__: process.env.COLLECT_API_ENDPOINT || '/api/send', diff --git a/src/tracker/index.d.ts b/src/tracker/index.d.ts index 46c5d026d..925e075cd 100644 --- a/src/tracker/index.d.ts +++ b/src/tracker/index.d.ts @@ -1,3 +1,4 @@ +/** Public types for the browser tracker. */ export type TrackedProperties = { /** * Hostname of server @@ -5,48 +6,46 @@ export type TrackedProperties = { * @description extracted from `window.location.hostname` * @example 'analytics.umami.is' */ - hostname: string; - + 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; - + language?: string; /** * Page referrer * - * @description extracted from `window.navigator.language` + * @description extracted from `document.referrer` * @example 'https://analytics.umami.is/docs/getting-started' */ - referrer: string; - + referrer?: string; /** * Screen dimensions * * @description extracted from `window.screen.width` and `window.screen.height` * @example '1920x1080', '2560x1440' */ - screen: string; - + screen?: string; + /** Tag configured on the tracker script. */ + tag?: string; /** * Page title * * @description extracted from `document.querySelector('head > title')` * @example 'umami' */ - title: string; - + title?: string; /** * Page url * - * @description built from `${window.location.pathname}${window.location.search}` - * @example 'docs/getting-started' + * @description normalized from `window.location.href` + * @example 'https://analytics.umami.is/docs/getting-started' */ - url: string; - + url?: string; /** * Website ID (required) * @@ -54,9 +53,10 @@ export type TrackedProperties = { */ website: string; }; - -export type WithRequired = T & { [P in K]-?: T[P] }; - +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. @@ -66,9 +66,8 @@ export type WithRequired = T & { [P in K]-?: T[P] }; * - Objects have a max of 50 properties. Arrays are considered 1 property. */ export interface EventData { - [key: string]: boolean | number | string | EventData | boolean[] | number[] | string[] | EventData[]; + [key: string]: EventDataValue; } - export type EventProperties = { /** * NOTE: event names will be truncated past 50 characters @@ -80,7 +79,6 @@ export type PageViewProperties = WithRequired; export type CustomEventFunction = ( props: PageViewProperties, ) => EventProperties | PageViewProperties; - export type UmamiTracker = { track: { /** @@ -90,8 +88,7 @@ export type UmamiTracker = { * umami.track(); * ``` */ - (): Promise; - + (): Promise; /** * Track an event with a given name * @@ -101,8 +98,7 @@ export type UmamiTracker = { * umami.track('signup-button'); * ``` */ - (eventName: string): Promise; - + (eventName: string): Promise; /** * Tracks an event with dynamic data. * @@ -125,8 +121,7 @@ export type UmamiTracker = { * umami.track('signup-button', { name: 'newsletter', id: 123 }); * ``` */ - (eventName: string, obj: EventData): Promise; - + (eventName: string, obj: EventData): Promise; /** * Tracks a page view with custom properties * @@ -134,8 +129,7 @@ export type UmamiTracker = { * umami.track({ website: 'e676c9b4-11e4-4ef1-a4d7-87001773e9f2', url: '/home', title: 'Home page' }); * ``` */ - (properties: PageViewProperties): Promise; - + (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 @@ -144,7 +138,7 @@ export type UmamiTracker = { * umami.track((props) => ({ ...props, url: path })); * ``` */ - (eventFunction: CustomEventFunction): Promise; + (eventFunction: CustomEventFunction): Promise; }; identify: { /** @@ -154,19 +148,25 @@ export type UmamiTracker = { * umami.identify('user-123', { plan: 'pro' }); * ``` */ - (id: string, data?: EventData): Promise; - + (id: string, data?: EventData): Promise; /** - * Associate data with the current visitor without setting an identity string. + * Associate data with the current visitor. An `id` string sets the Distinct ID. * * @example ``` - * umami.identify({ plan: 'pro' }); + * umami.identify({ id: 'user-123', plan: 'pro' }); * ``` */ - (data: EventData): Promise; + ( + data: EventData & { + id?: string; + }, + ): Promise; + }; + getSession: () => { + cache: string | undefined; + website: string | null; }; }; - declare global { interface Window { umami: UmamiTracker; diff --git a/src/tracker/index.js b/src/tracker/index.ts similarity index 55% rename from src/tracker/index.js rename to src/tracker/index.ts index 314c11acb..ab5d868e1 100644 --- a/src/tracker/index.js +++ b/src/tracker/index.ts @@ -1,3 +1,215 @@ +/** 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 }, @@ -8,12 +220,12 @@ top, doNotTrack, } = window; - const { currentScript, referrer } = document; + const { currentScript, referrer } = document as TrackerDocument; if (!currentScript) return; const { hostname, href, origin } = location; - let localStorage; + let localStorage: Storage | undefined; try { localStorage = href.startsWith('data:') ? undefined : window.localStorage; } catch { @@ -24,7 +236,7 @@ const _false = 'false'; const _true = 'true'; const attr = currentScript.getAttribute.bind(currentScript); - const config = value => attr(`${_data}${value}`); + const config = (value: string) => attr(`${_data}${value}`); const website = config('website-id'); const hostUrl = config('host-url'); @@ -35,7 +247,7 @@ const excludeSearch = config('exclude-search') === _true; const excludeHash = config('exclude-hash') === _true; const domain = config('domains') || ''; - const credentials = config('fetch-credentials') || 'omit'; + const credentials = (config('fetch-credentials') || 'omit') as RequestCredentials; const perf = config('performance') === _true; const autoPageview = config('auto-pageview') !== _false; @@ -50,15 +262,15 @@ /* Helper functions */ - const normalize = raw => { - if (!raw) return raw; + 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; + return raw as string; } }; @@ -81,7 +293,7 @@ /* Event handlers */ - const handlePush = (_state, _title, url) => { + const handlePush = (_state: unknown, _title: string, url?: string | URL | null) => { if (!url) return; if (typeof flushPerformance === 'function') { @@ -97,9 +309,13 @@ }; const handlePathChanges = () => { - const hook = (_this, method, callback) => { + const hook = ( + _this: History, + method: 'pushState' | 'replaceState', + callback: typeof handlePush, + ) => { const orig = _this[method]; - return (...args) => { + return (...args: Parameters) => { const result = orig.apply(_this, args); callback.apply(null, args); return result; @@ -111,26 +327,26 @@ }; const handleClicks = () => { - const trackElement = async el => { + const trackElement = async (el: Element) => { const eventName = el.getAttribute(eventNameAttribute); if (eventName) { - const eventData = {}; + const eventData: EventData = {}; el.getAttributeNames().forEach(name => { const match = name.match(eventRegex); - if (match) eventData[match[1]] = el.getAttribute(name); + if (match) eventData[match[1]] = el.getAttribute(name) as string; }); return track(eventName, eventData); } }; - const onClick = e => { - const el = e.target; + const onClick = (e: MouseEvent) => { + const el = e.target as Element; const eventEl = el.closest(`[${eventNameAttribute}]`); if (!eventEl) return; - if (eventEl.tagName === 'A' && eventEl.href) { - const { href, target } = eventEl; + if (eventEl.tagName === 'A' && (eventEl as HTMLAnchorElement).href) { + const { href, target } = eventEl as HTMLAnchorElement; const external = target === '_blank' || e.ctrlKey || @@ -140,7 +356,7 @@ if (!external) e.preventDefault(); return trackElement(eventEl).finally(() => { if (!external) { - (target === '_top' ? top.location : location).href = href; + (target === '_top' ? (top as WindowProxy).location : location).href = href; } }); } @@ -159,13 +375,15 @@ (domain && !domains.includes(hostname)) || (dnt && hasDoNotTrack()); - const send = async (payload, type = 'event') => { + const send = async (payload: Payload | null | undefined, type = 'event'): Promise => { if (trackingDisabled()) return; - const callback = window[beforeSend]; + const callback = (window as unknown as Record)[beforeSend as string] as + | BeforeSend + | undefined; if (typeof callback === 'function') { - payload = await Promise.resolve(callback(type, payload)); + payload = await Promise.resolve(callback(type, payload as Payload)); } if (!payload) return; @@ -177,14 +395,14 @@ body: JSON.stringify({ type, payload }), headers: { 'Content-Type': 'application/json', - 'x-umami-website-id': website, + 'x-umami-website-id': website as string, 'x-umami-hostname': hostname, ...(typeof cache !== 'undefined' && { 'x-umami-cache': cache }), }, credentials, }); - const data = await res.json(); + const data = (await res.json()) as { cache?: string; disabled?: boolean } | null; if (data) { disabled = !!data.disabled; cache = data.cache; @@ -205,16 +423,24 @@ } }; - const track = (name, data) => { + 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, data) => { - if (typeof id === 'string') { - identity = id; + const identify = ( + id: string | (EventData & { id?: string }), + data?: EventData, + ): Promise => { + const nextIdentity = typeof id === 'string' ? id : id.id; + + if (nextIdentity !== undefined) { + identity = nextIdentity; } cache = ''; @@ -230,17 +456,17 @@ /* Performance */ const initPerformance = () => { - const metrics = {}; + const metrics: Record = {}; let sent = false; - let timeoutId; + let timeoutId: ReturnType | undefined; let isInitialLoad = true; let activationStart = 0; let pageStartTime = 0; - const observe = (type, callback) => { + const observe = (type: string, callback: (entry: MetricEntry) => void) => { try { const observer = new PerformanceObserver(list => { - list.getEntries().forEach(callback); + (list.getEntries() as MetricEntry[]).forEach(callback); }); observer.observe({ type, buffered: true }); } catch { @@ -268,7 +494,7 @@ // CLS - session windows algorithm (gap < 1s, max 5s duration; report worst window) let clsSessionValue = 0; - let clsSessionEntries = []; + let clsSessionEntries: MetricEntry[] = []; observe('layout-shift', entry => { if (!entry.hadRecentInput) { const lastEntry = clsSessionEntries[clsSessionEntries.length - 1]; @@ -291,10 +517,10 @@ }); // INP - group by interactionId, 98th percentile, 40ms threshold - let interactions = {}; - let inpObserver; - const recordInteractions = entries => { - entries.forEach(entry => { + 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) { @@ -305,7 +531,11 @@ }; try { inpObserver = new PerformanceObserver(list => recordInteractions(list.getEntries())); - inpObserver.observe({ type: 'event', buffered: true, durationThreshold: 40 }); + inpObserver.observe({ + type: 'event', + buffered: true, + durationThreshold: 40, + } as PerformanceObserverInit); } catch { /* not supported */ } @@ -319,9 +549,9 @@ } }; - const getEntriesByType = type => { + const getEntriesByType = (type: string): MetricEntry[] => { try { - return window.performance?.getEntriesByType?.(type) || []; + return (window.performance?.getEntriesByType?.(type) as MetricEntry[]) || []; } catch { return []; } @@ -397,7 +627,7 @@ track, identify, getSession: () => ({ cache, website }), - }; + } as UmamiTracker; } let currentUrl = normalize(href); @@ -405,9 +635,9 @@ let initialized = false; let disabled = false; - let cache; - let identity; - let flushPerformance; + let cache: string | undefined; + let identity: string | undefined; + let flushPerformance: (() => void) | undefined; if (autoTrack && !trackingDisabled()) { if (document.readyState === 'complete') { @@ -416,4 +646,4 @@ document.addEventListener('readystatechange', init, true); } } -})(window); +})(window as TrackerWindow); diff --git a/tsconfig.tracker.json b/tsconfig.tracker.json new file mode 100644 index 000000000..d0f38985f --- /dev/null +++ b/tsconfig.tracker.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "incremental": false, + "noEmit": true, + "noImplicitAny": true, + "outDir": "./public", + "rootDir": "./src/tracker", + "sourceMap": false, + "strict": true, + "strictNullChecks": true, + "target": "es2022" + }, + "include": ["src/tracker/**/*.ts"], + "exclude": ["src/tracker/**/*.test.ts"] +} diff --git a/tsconfig.tracker.types.json b/tsconfig.tracker.types.json new file mode 100644 index 000000000..c6a8f7707 --- /dev/null +++ b/tsconfig.tracker.types.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.tracker.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "./src/tracker", + "removeComments": false, + "rootDir": "./src/tracker" + }, + "include": ["src/tracker/index.ts"], + "exclude": [] +}