├── .gitignore ├── jest ├── fetch.js ├── mutation-observer.js └── test-utils.tsx ├── tsconfig.json ├── .github └── workflows │ └── main.yml ├── LICENSE ├── package.json ├── README.md ├── test └── index.test.tsx └── src └── index.tsx /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /jest/fetch.js: -------------------------------------------------------------------------------- 1 | // Next.js has its own fetch polyfill, but we don't have next when testing. 2 | // So we provide a fetch polyfill. 3 | // require('unfetch/polyfill'); 4 | global.fetch = jest.fn(); 5 | -------------------------------------------------------------------------------- /jest/mutation-observer.js: -------------------------------------------------------------------------------- 1 | // This polyfill is necessary until tsdx upgrades to the latest jest and jsdom 2 | // https://github.com/testing-library/dom-testing-library/releases/tag/v7.0.0 3 | window.MutationObserver = require('@sheerun/mutationobserver-shim'); 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": "./", 17 | "paths": { 18 | "*": ["src/*", "node_modules/*"] 19 | }, 20 | "jsx": "react", 21 | "esModuleInterop": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Lint 30 | run: yarn lint 31 | env: 32 | CI: true 33 | 34 | - name: Test 35 | run: yarn test --ci --coverage --maxWorkers=2 36 | env: 37 | CI: true 38 | 39 | - name: Build 40 | run: yarn build 41 | env: 42 | CI: true 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dominik Ferber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /jest/test-utils.tsx: -------------------------------------------------------------------------------- 1 | // taken from https://github.com/vercel/next.js/issues/7479#issuecomment-659859682 2 | import React from 'react'; 3 | import { render as defaultRender } from '@testing-library/react'; 4 | import { RouterContext } from 'next/dist/next-server/lib/router-context'; 5 | import { NextRouter } from 'next/router'; 6 | 7 | export * from '@testing-library/react'; 8 | 9 | const mockRouter: NextRouter = { 10 | basePath: '', 11 | pathname: '/', 12 | route: '/', 13 | asPath: '/', 14 | query: {}, 15 | push: jest.fn(), 16 | replace: jest.fn(), 17 | reload: jest.fn(), 18 | back: jest.fn(), 19 | prefetch: jest.fn(), 20 | beforePopState: jest.fn(), 21 | events: { 22 | on: jest.fn(), 23 | off: jest.fn(), 24 | emit: jest.fn(), 25 | }, 26 | isFallback: false, 27 | }; 28 | 29 | // -------------------------------------------------- 30 | // Override the default test render with our own 31 | // 32 | // You can override the router mock like this: 33 | // 34 | // const { baseElement } = render(, { 35 | // router: { pathname: '/my-custom-pathname' }, 36 | // }); 37 | // -------------------------------------------------- 38 | type DefaultParams = Parameters; 39 | type RenderUI = DefaultParams[0]; 40 | type RenderOptions = DefaultParams[1] & { router?: Partial }; 41 | 42 | export function render( 43 | ui: RenderUI, 44 | { wrapper, router, ...options }: RenderOptions = {} 45 | ) { 46 | if (!wrapper) { 47 | wrapper = ({ children }) => ( 48 | 49 | {children} 50 | 51 | ); 52 | } 53 | 54 | return defaultRender(ui, { wrapper, ...options }); 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.1", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test --passWithNoTests", 17 | "test:watch": "tsdx test --watch", 18 | "lint": "tsdx lint", 19 | "prepare": "tsdx build", 20 | "release": "np" 21 | }, 22 | "peerDependencies": { 23 | "next": ">=9.1.7", 24 | "react": ">=16" 25 | }, 26 | "husky": { 27 | "hooks": { 28 | "pre-commit": "tsdx lint" 29 | } 30 | }, 31 | "prettier": { 32 | "printWidth": 80, 33 | "semi": true, 34 | "singleQuote": true, 35 | "trailingComma": "es5" 36 | }, 37 | "repository": "happykit/analytics", 38 | "homepage": "https://happykit.dev", 39 | "name": "@happykit/analytics", 40 | "author": "Dominik Ferber", 41 | "module": "dist/analytics.esm.js", 42 | "jest": { 43 | "setupFiles": [ 44 | "./jest/fetch.js", 45 | "./jest/mutation-observer.js" 46 | ] 47 | }, 48 | "publishConfig": { 49 | "access": "public" 50 | }, 51 | "devDependencies": { 52 | "@sheerun/mutationobserver-shim": "0.3.3", 53 | "@testing-library/jest-dom": "5.11.2", 54 | "@testing-library/react": "10.4.7", 55 | "@types/react": "16.9.43", 56 | "@types/react-dom": "16.9.8", 57 | "babel-jest": "26.2.2", 58 | "husky": "4.2.5", 59 | "msw": "0.20.1", 60 | "next": "9.5.1", 61 | "np": "6.3.2", 62 | "react": "16.13.1", 63 | "react-dom": "16.13.1", 64 | "tsdx": "0.13.2", 65 | "tslib": "2.0.0", 66 | "typescript": "3.9.7", 67 | "unfetch": "4.1.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | Website 7 |  •  8 | Twitter 9 |
10 | 11 |   12 |   13 | 14 | Add analytics to your Next.js application with a single React Hook. This package integrates your Next.js application with HappyKit Analytics. Create a free [happykit.dev](https://happykit.dev/signup) account to get started. 15 | 16 | ## Key Features 17 | 18 | - Track page views and unique visitors 19 | - Integrate using a single React Hook 20 | - Next.js specific dynamic route tracking (e.g. `/[user]`) 21 | - GDPR compliant by default. No cookie notice necessary. 22 | - Tiny: [1kB minified + gzipped](https://bundlephobia.com/result?p=@happykit/analytics) 23 | - No external runtime, so no costly additional request to load a runtime 24 | - Written in TypeScript 25 | 26 | ## Installation 27 | 28 | Add the package to your project 29 | 30 | ```sh 31 | npm install @happykit/analytics 32 | ``` 33 | 34 | ## Integration 35 | 36 | You'll need to add a single `useAnalytics` call to your application. The best place to do this is in `pages/_app.js`. 37 | 38 | Set up a `pages/_app.js` file with this content: 39 | 40 | ```js 41 | import { useAnalytics } from '@happykit/analytics'; 42 | 43 | function MyApp({ Component, pageProps }) { 44 | useAnalytics({ publicKey: '' }); // <-- add this 45 | 46 | return ; 47 | } 48 | 49 | export default MyApp; 50 | ``` 51 | 52 | > Create a free account on [happykit.dev](https://happykit.dev/signup) to get your _HappyKit Public Key_ 53 | 54 |
55 | Using TypeScript? 56 | 57 | ```ts 58 | import { useAnalytics } from '@happykit/analytics'; 59 | import type { AppProps } from 'next/app' 60 | 61 | function MyApp({ Component, pageProps }: AppProps) { 62 | useAnalytics({ publicKey: 'HAPPYKIT KEY' }); // <-- add this 63 | 64 | return 65 | } 66 | 67 | export default MyApp 68 | ``` 69 | 70 |
71 | 72 | You can read more about using a custom `_app.js` component [here](https://nextjs.org/docs/advanced-features/custom-app). 73 | 74 | ## Options 75 | 76 | `useAnalytics(options)` accepts the following options object: 77 | 78 | - `publicKey` **string** (required): The public key for this project from [happykit.dev](https://happykit.dev/). 79 | - `skip` **function** (optional): This function is called with the created page view record. Return true to avoid tracking it. 80 | - `skipHostnames` **array of strings** (optional): An array of hostnames which will not be tracked. Defaults to `["localhost"]`. HappyKit tracks page views from preview deployments by default. The data is kept separate from your production data. 81 | - `delay` **number** (optional): The number of milliseconds to wait before reporting a page view. Defaults to 5000. This is used for batching purposes. This is used only if the browser supports `navigator.sendBeacon`. Otherwise page views are sent immediately. 82 | 83 | Example: 84 | 85 | ```js 86 | useAnalytics({ 87 | publicKey: 'pk_live_5093bcd381', 88 | skip: pageView => pageView.pathname === '/some-ignored-path', 89 | }); 90 | ``` 91 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import * as React from 'react'; 3 | import { render, screen, act } from '../jest/test-utils'; 4 | import { useAnalytics, Analytics } from '../src'; 5 | 6 | // navigator.sendBeacon is not defined in jsdom, so the 7 | // useAnalytics function will always send page views direclty through fetch. 8 | 9 | type PageViewBody = { publicKey: string; views: Analytics[] }; 10 | 11 | beforeEach(() => { 12 | global.fetch = jest.fn(); 13 | }); 14 | 15 | declare var fetch: jest.Mock; 16 | declare var navigator: Navigator & { 17 | sendBeacon: jest.Mock; 18 | }; 19 | 20 | function parseBody( 21 | mockContext: jest.MockContext['calls'][0] 22 | ): PageViewBody { 23 | return JSON.parse(mockContext[1].body); 24 | } 25 | 26 | describe('when called without a publicKey', () => { 27 | // Silence error message logging, while keeping other logs alive 28 | let err = console.error; 29 | beforeEach(() => { 30 | console.error = jest.fn(); 31 | }); 32 | afterEach(() => { 33 | console.error = err; 34 | }); 35 | it('shoud throw an error', () => { 36 | function TestComponent() { 37 | useAnalytics({ publicKey: '' }); 38 | return null; 39 | } 40 | 41 | expect.assertions(1); 42 | 43 | expect(() => { 44 | render(); 45 | }).toThrowError('@happykit/analytics: missing options.publicKey'); 46 | }); 47 | }); 48 | 49 | describe('when using fetch', () => { 50 | it('calls the api', async () => { 51 | function TestComponent() { 52 | useAnalytics({ publicKey: 'pk_test', skipHostnames: [] }); 53 | return

content

; 54 | } 55 | 56 | render(, { 57 | router: { pathname: '/[place]', asPath: '/home' }, 58 | }); 59 | 60 | expect(fetch).toHaveBeenCalledTimes(1); 61 | expect(fetch).toHaveBeenCalledWith( 62 | 'https://happykit.dev/api/pv', 63 | expect.objectContaining({ 64 | body: expect.any(String), 65 | }) 66 | ); 67 | 68 | const body = parseBody(fetch.mock.calls[0]); 69 | expect(body.publicKey).toEqual('pk_test'); 70 | 71 | expect(body.views).toHaveLength(1); 72 | 73 | const view = body.views[0]; 74 | expect(view.hostname).toBe('localhost'); 75 | expect(view.pathname).toBe('/home'); 76 | expect(view.route).toBe('/[place]'); 77 | expect(view.ua).toMatch(/jsdom/); 78 | expect(typeof view.timeZone).toBe('string'); 79 | expect(view.referrer).toBe(''); 80 | expect(view.referrerHostname).toBe(undefined); 81 | expect(view.referrerPathname).toBe(undefined); 82 | expect(view.urlReferrer).toBe(undefined); 83 | expect(typeof view.time).toBe('number'); 84 | 85 | const content = await screen.findByText('content'); 86 | expect(content).toBeInTheDocument(); 87 | }); 88 | 89 | it('accepts a custom apiRoute', async () => { 90 | function TestComponent() { 91 | useAnalytics({ 92 | apiRoute: '/foo', 93 | publicKey: 'pk_test', 94 | skipHostnames: [], 95 | }); 96 | return null; 97 | } 98 | 99 | render(); 100 | 101 | expect(fetch).toHaveBeenCalledTimes(1); 102 | expect(fetch).toHaveBeenCalledWith( 103 | '/foo', 104 | expect.objectContaining({ body: expect.any(String) }) 105 | ); 106 | 107 | const body = parseBody(fetch.mock.calls[0]); 108 | expect(body.publicKey).toEqual('pk_test'); 109 | }); 110 | 111 | it('accepts a custom apiRoute', async () => { 112 | function TestComponent() { 113 | useAnalytics({ 114 | apiRoute: '/foo', 115 | publicKey: 'pk_test', 116 | skipHostnames: [], 117 | }); 118 | return null; 119 | } 120 | 121 | render(, { 122 | router: { pathname: '/home', route: '/home', asPath: '/' }, 123 | }); 124 | 125 | expect(fetch).toHaveBeenCalledTimes(1); 126 | expect(fetch).toHaveBeenCalledWith( 127 | '/foo', 128 | expect.objectContaining({ body: expect.any(String) }) 129 | ); 130 | 131 | const body = parseBody(fetch.mock.calls[0]); 132 | expect(body.publicKey).toEqual('pk_test'); 133 | }); 134 | 135 | it('respects the given skip function', async () => { 136 | const skip = jest.fn(() => true); 137 | function TestComponent() { 138 | useAnalytics({ 139 | apiRoute: '/foo', 140 | publicKey: 'pk_test', 141 | skipHostnames: [], 142 | skip, 143 | }); 144 | return null; 145 | } 146 | 147 | render(); 148 | 149 | expect(skip).toHaveBeenCalledTimes(1); 150 | expect(skip).toHaveBeenCalledWith( 151 | expect.objectContaining({ 152 | hostname: 'localhost', 153 | pathname: '/', 154 | referrer: '', 155 | referrerHostname: undefined, 156 | referrerPathname: undefined, 157 | route: '/', 158 | time: expect.any(Number), 159 | timeZone: expect.any(String), 160 | ua: expect.any(String), 161 | unique: expect.any(Boolean), 162 | urlReferrer: undefined, 163 | width: expect.any(Number), 164 | }) 165 | ); 166 | expect(fetch).toHaveBeenCalledTimes(0); 167 | }); 168 | 169 | it('should not call the api from localhost', async () => { 170 | function TestComponent() { 171 | // note that skipHostnames defaults to ["localhost"], 172 | // and the request should thus be skipped 173 | useAnalytics({ publicKey: 'pk_test' }); 174 | return null; 175 | } 176 | 177 | render(); 178 | expect(fetch).toHaveBeenCalledTimes(0); 179 | }); 180 | }); 181 | 182 | describe('when using navigator.sendBeacon', () => { 183 | let n: typeof global.navigator.sendBeacon; 184 | beforeEach(() => { 185 | n = global.navigator.sendBeacon; 186 | global.navigator.sendBeacon = jest.fn(); 187 | }); 188 | afterEach(() => { 189 | global.navigator.sendBeacon = n; 190 | }); 191 | 192 | it('calls the api', async () => { 193 | jest.useFakeTimers(); 194 | 195 | function TestComponent() { 196 | useAnalytics({ publicKey: 'pk_test', skipHostnames: [] }); 197 | return null; 198 | } 199 | 200 | render(); 201 | 202 | // not called yet because it gets queued 203 | expect(navigator.sendBeacon).toHaveBeenCalledTimes(0); 204 | 205 | act(() => { 206 | jest.runAllTimers(); 207 | }); 208 | 209 | // called now because we forwarded the timers 210 | expect(navigator.sendBeacon).toHaveBeenCalledTimes(1); 211 | 212 | expect(navigator.sendBeacon).toHaveBeenCalledWith( 213 | 'https://happykit.dev/api/pv', 214 | expect.any(String) 215 | ); 216 | 217 | const body: PageViewBody = JSON.parse( 218 | navigator.sendBeacon.mock.calls[0][1] 219 | ); 220 | expect(body.views).toHaveLength(1); 221 | expect(body.publicKey).toBe('pk_test'); 222 | const view = body.views[0]; 223 | expect(view).toEqual( 224 | expect.objectContaining({ 225 | hostname: 'localhost', 226 | pathname: '/', 227 | referrer: '', 228 | route: '/', 229 | time: expect.any(Number), 230 | timeZone: expect.any(String), 231 | ua: expect.any(String), 232 | unique: expect.any(Boolean), 233 | width: expect.any(Number), 234 | }) 235 | ); 236 | expect(view).not.toHaveProperty('referrerHostname'); 237 | expect(view).not.toHaveProperty('referrerPathname'); 238 | expect(view).not.toHaveProperty('urlReferrer'); 239 | 240 | // never called because we're using navigator.sendBeacon 241 | expect(fetch).toHaveBeenCalledTimes(0); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | 4 | export type Analytics = { 5 | /** 6 | * The current hostname of the page 7 | */ 8 | hostname: string; 9 | /** 10 | * The current path of the page 11 | */ 12 | pathname: string; 13 | /** 14 | * The Next.js route of the page. That is the path of the page in /pages. 15 | */ 16 | route: string; 17 | /** 18 | * User Agent 19 | * This is overwritten by the User Agent of the request headers in case they 20 | * are defined. 21 | */ 22 | ua?: string; 23 | /** 24 | * The viewport width of the device (not possible via server) 25 | */ 26 | width?: number; 27 | /** 28 | * This tells us if this visit is unique. 29 | */ 30 | unique?: boolean; 31 | /** 32 | * The time zone of the current user so we can detect the country 33 | */ 34 | timeZone?: string; 35 | /** 36 | * The full referrer url of the current page (if any) 37 | */ 38 | referrer?: string; 39 | /** 40 | * The referrer hostname of the current page (if any), or the value of ?ref 41 | */ 42 | referrerHostname?: string; 43 | /** 44 | * The referrer pathname of the current page (if any). 45 | * Undefined in case ?ref was set. 46 | */ 47 | referrerPathname?: string; 48 | /** 49 | * The value of ?ref=... in the URL (more info: https://docs.simpleanalytics.com/how-to-use-url-parameters) 50 | */ 51 | urlReferrer?: string; 52 | /** 53 | * The time when the view occurred. Use Date.now() to set it. 54 | */ 55 | time: number; 56 | }; 57 | 58 | export type AnalyticsConfig = { 59 | /** 60 | * The public key for this project from happykit.dev. 61 | */ 62 | publicKey: string; 63 | apiRoute?: string; 64 | /** 65 | * An optional function. Return true to avoid tracking certain page views. 66 | */ 67 | skip?: (pageView: Analytics) => boolean; 68 | /** 69 | * An array of hostnames whose page views will not get tracked. 70 | * 71 | * Views from "localhost" are skipped by default. Make sure to readd localhost 72 | * when you provide your own array. You can provide an empty array to track 73 | * views from localhost. 74 | */ 75 | skipHostnames?: string[]; 76 | /** 77 | * Duration in milliseconds to wait since the last page view before sending 78 | * page views. 79 | */ 80 | delay?: number; 81 | }; 82 | 83 | type AnalyticsReducerState = { 84 | queue: Analytics[]; 85 | hadFirstPageView: boolean; 86 | lastPathname: string; 87 | }; 88 | 89 | type AnalyticsReducerAction = 90 | | { 91 | type: 'view'; 92 | payload: Analytics; 93 | } 94 | | { type: 'clear'; payload: Analytics[] }; 95 | 96 | function analyticsReducer( 97 | state: AnalyticsReducerState, 98 | action: AnalyticsReducerAction 99 | ): AnalyticsReducerState { 100 | if (action.type === 'view') { 101 | const view = 102 | state.hadFirstPageView && action.payload.unique 103 | ? // subsequent page views can never be unique, so we overwrite it 104 | { ...action.payload, unique: false } 105 | : action.payload; 106 | 107 | // Avoid adding duplicate. 108 | // We should only need this in case a React Fast Refresh happened 109 | if (view.pathname === state.lastPathname) return state; 110 | 111 | return { 112 | ...state, 113 | queue: [...state.queue, view], 114 | hadFirstPageView: true, 115 | lastPathname: view.pathname, 116 | }; 117 | } 118 | 119 | if (action.type === 'clear') { 120 | return { 121 | ...state, 122 | queue: state.queue.filter(view => !action.payload.includes(view)), 123 | }; 124 | } 125 | 126 | return state; 127 | } 128 | 129 | // the url we are passed looks like /projects?x=5 130 | // but we are only interested in /projects 131 | const omitQueryFromPathname = (url: string) => { 132 | const u = new URL(`http://e.de${url}`); 133 | return u.pathname; 134 | }; 135 | 136 | function useAnalyticsServer() {} 137 | function useAnalyticsClient({ 138 | apiRoute = 'https://happykit.dev/api/pv', 139 | publicKey, 140 | skip, 141 | skipHostnames = ['localhost'], 142 | delay = 5000, 143 | }: AnalyticsConfig) { 144 | if (!publicKey) { 145 | throw new Error('@happykit/analytics: missing options.publicKey'); 146 | } 147 | const router = useRouter(); 148 | const [state, send] = React.useReducer(analyticsReducer, { 149 | queue: [], 150 | hadFirstPageView: false, 151 | lastPathname: '', 152 | }); 153 | 154 | const view = React.useMemo(() => { 155 | const referrer = 156 | document.referrer && 157 | new URL(document.referrer).hostname === window.location.hostname 158 | ? '' 159 | : document.referrer; 160 | 161 | const searchParams = new URLSearchParams(window.location.search); 162 | const urlReferrer = searchParams.get('ref') || undefined; 163 | return { 164 | hostname: window.location.hostname, 165 | // Actual path (excluding the query) shown in the browser 166 | pathname: omitQueryFromPathname(router.asPath), 167 | // The Next.js route. That is the path of the page in `/pages`. 168 | route: router.pathname, 169 | ua: navigator.userAgent, 170 | width: window.innerWidth, 171 | timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, 172 | referrer, 173 | urlReferrer, 174 | // This gets overwritten by the reducer, as subsequent page views can 175 | // never be unique 176 | unique: (() => { 177 | if (document.referrer === '') return true; 178 | 179 | const ref = new URL(document.referrer); 180 | return ref.hostname !== window.location.hostname; 181 | })(), 182 | referrerHostname: urlReferrer 183 | ? urlReferrer 184 | : referrer 185 | ? new URL(referrer).hostname 186 | : undefined, 187 | referrerPathname: urlReferrer 188 | ? undefined 189 | : referrer 190 | ? new URL(referrer).pathname 191 | : undefined, 192 | time: Date.now(), 193 | }; 194 | }, [router.pathname, router.asPath]); 195 | 196 | const skipped = // skip ignored hostnames 197 | (Array.isArray(skipHostnames) && 198 | skipHostnames.some( 199 | hostname => hostname.toLowerCase() === view.hostname.toLowerCase() 200 | )) || 201 | // skip events as defined by user 202 | (typeof skip === 'function' && skip(view)); 203 | 204 | React.useEffect(() => { 205 | if (skipped) return; 206 | 207 | send({ type: 'view', payload: view }); 208 | }, [view, skipped]); 209 | 210 | const queue = state.queue; 211 | const sendQueue = React.useCallback(() => { 212 | if (queue.length === 0) return; 213 | const views = [...queue]; 214 | 215 | send({ type: 'clear', payload: views }); 216 | if (typeof navigator.sendBeacon === 'function') { 217 | navigator.sendBeacon(apiRoute, JSON.stringify({ publicKey, views })); 218 | } else { 219 | const body = JSON.stringify({ publicKey, views }); 220 | // Since sendBeacon sends a plain string, we don't set any 221 | // content-type headers on this request either. 222 | // 223 | // That way the server can parse the request body from a string. 224 | fetch(apiRoute, { method: 'POST', keepalive: true, body }); 225 | } 226 | }, [queue, apiRoute, publicKey, send]); 227 | 228 | React.useEffect(() => { 229 | if (state.queue.length === 0) return; 230 | 231 | const supportsBeacon = typeof navigator.sendBeacon === 'function'; 232 | // sendBeacon works even when a browser window is being closed. 233 | // It's supported in most major browsers. We queue events in case 234 | // we can use sendBeacon, otherwise we send them live. 235 | const adjustedDelay = supportsBeacon ? delay : 0; 236 | 237 | if (!supportsBeacon) { 238 | sendQueue(); 239 | return; 240 | } 241 | 242 | const timer = setTimeout(sendQueue, adjustedDelay); 243 | 244 | return () => { 245 | clearTimeout(timer); 246 | }; 247 | }, [state, apiRoute, delay, sendQueue]); 248 | 249 | // send anything that hasn't been sent yet 250 | React.useEffect(() => { 251 | window.addEventListener('beforeunload', sendQueue, { once: true }); 252 | return () => { 253 | window.removeEventListener('beforeunload', sendQueue); 254 | }; 255 | }, [sendQueue]); 256 | } 257 | 258 | // this runs on the client only 259 | export const useAnalytics = 260 | typeof window === 'undefined' ? useAnalyticsServer : useAnalyticsClient; 261 | --------------------------------------------------------------------------------