├── .github └── FUNDING.yml ├── .gitignore ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── src ├── format.js ├── ga4.js ├── ga4.test.js ├── gtag.js └── index.js └── types ├── format.d.ts ├── ga4.d.ts ├── gtag.d.ts └── index.d.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [codler] 2 | custom: ['https://www.paypal.me/hanlinyap'] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ✨ Looking for sponsors! ✨ 2 | 3 | Maintainer need help with sponsor! 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
Han Lin Yap
Han Lin Yap

🚧
12 | 13 | # React Google Analytics 4 14 | 15 | ## Migrate from old react-ga 16 | 17 | ```js 18 | // Simply replace `react-ga` with `react-ga4` and remove `ReactGA.pageview()` 19 | // import ReactGA from "react-ga"; 20 | import ReactGA from "react-ga4"; 21 | ``` 22 | 23 | ## Install 24 | 25 | ```bash 26 | npm i react-ga4 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```js 32 | import ReactGA from "react-ga4"; 33 | 34 | ReactGA.initialize("your GA measurement id"); 35 | ``` 36 | 37 | ## Example 38 | 39 | More example can be found in [test suite](src/ga4.test.js) 40 | 41 | ```js 42 | // Multiple products (previously known as trackers) 43 | ReactGA.initialize([ 44 | { 45 | trackingId: "your GA measurement id", 46 | gaOptions: {...}, // optional 47 | gtagOptions: {...}, // optional 48 | }, 49 | { 50 | trackingId: "your second GA measurement id", 51 | }, 52 | ]); 53 | 54 | // Send pageview with a custom path 55 | ReactGA.send({ hitType: "pageview", page: "/my-path", title: "Custom Title" }); 56 | 57 | // Send a custom event 58 | ReactGA.event({ 59 | category: "your category", 60 | action: "your action", 61 | label: "your label", // optional 62 | value: 99, // optional, must be a number 63 | nonInteraction: true, // optional, true/false 64 | transport: "xhr", // optional, beacon/xhr/image 65 | }); 66 | ``` 67 | 68 | ## Reference 69 | 70 | #### ReactGA.initialize(GA_MEASUREMENT_ID, options) 71 | 72 | | Parameter | Notes | 73 | | ------------------- | ----------------------------------------------------------------------------------------------------------------------- | 74 | | GA_MEASUREMENT_ID | `string` Required | 75 | | options.nonce | `string` Optional Used for Content Security Policy (CSP) [more](https://developers.google.com/tag-manager/web/csp) | 76 | | options.testMode | `boolean` Default false | 77 | | options.gtagUrl | `string` Default `https://www.googletagmanager.com/gtag/js` | 78 | | options.gaOptions | `object` Optional [Reference](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference) | 79 | | options.gtagOptions | `object` Optional | 80 | 81 | #### ReactGA.set(fieldsObject) 82 | 83 | | Parameter | Notes | 84 | | ------------ | ----------------- | 85 | | fieldsObject | `object` Required | 86 | 87 | #### ReactGA.event(name, params) 88 | 89 | This method signature are NOT for `UA-XXX` 90 | 91 | | Parameter | Notes | 92 | | --------- | ----------------------------------------------------------------------------------------------------------------------------- | 93 | | name | `string` Required A [recommended event](https://developers.google.com/tag-platform/gtagjs/reference/events) or a custom event | 94 | | params | `object` Optional | 95 | 96 | #### ReactGA.event(options) 97 | 98 | | Parameter | Notes | 99 | | ---------------------- | ----------------------------------- | 100 | | options | `object` Required | 101 | | options.action | `string` Required | 102 | | options.category | `string` Required | 103 | | options.label | `string` Optional | 104 | | options.value | `number` Optional | 105 | | options.nonInteraction | `boolean` Optional | 106 | | options.transport | `'beacon'\|'xhr'\|'image'` Optional | 107 | 108 | #### ReactGA.send(fieldsObject) 109 | 110 | | Parameter | Notes | 111 | | ------------ | ----------------- | 112 | | fieldsObject | `object` Required | 113 | 114 | #### ReactGA.gtag(...args) 115 | 116 | #### ReactGA.ga(...args) 117 | 118 | ### Extending 119 | 120 | ```js 121 | import { ReactGAImplementation } from "react-ga4"; 122 | 123 | class MyCustomOverriddenClass extends ReactGAImplementation {} 124 | 125 | export default new MyCustomOverriddenClass(); 126 | ``` 127 | 128 | ## Debugging 129 | 130 | Use [Google Analytics Debugger Chrome Extension](https://chrome.google.com/webstore/detail/google-analytics-debugger/jnkmfdileelhofjcijamephohjechhna?hl=en) to see logs 131 | 132 | ## Maintainer 133 | 134 | [Han Lin Yap](https://github.com/codler) 135 | 136 | ## License 137 | 138 | MIT 139 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const { NODE_ENV } = process.env; 2 | 3 | const options = NODE_ENV === "test" ? { targets: { node: "current" } } : {}; 4 | 5 | module.exports = { 6 | presets: [["@babel/preset-env", options]], 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ga4", 3 | "version": "2.1.0", 4 | "description": "React Google Analytics 4", 5 | "main": "dist/index.js", 6 | "types": "types/index.d.ts", 7 | "scripts": { 8 | "build": "NODE_ENV=production babel src -d dist", 9 | "postbuild": "tsc src/index.js --declaration --allowJs --emitDeclarationOnly --outDir types", 10 | "prepublishOnly": "npm run build && npm test", 11 | "test": "NODE_ENV=test jest" 12 | }, 13 | "author": "Han Lin Yap (https://yap.nu)", 14 | "license": "MIT", 15 | "keywords": [ 16 | "GA", 17 | "GTM", 18 | "Google Analytics", 19 | "Google Analytics 4", 20 | "Google Tag Manager" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/codler/react-ga4.git" 25 | }, 26 | "devDependencies": { 27 | "@babel/cli": "^7.20.7", 28 | "@babel/core": "^7.20.12", 29 | "@babel/preset-env": "^7.20.2", 30 | "babel-jest": "^29.3.1", 31 | "jest": "^29.3.1", 32 | "typescript": "^4.9.4" 33 | }, 34 | "jest": { 35 | "testPathIgnorePatterns": [ 36 | "dist" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/format.js: -------------------------------------------------------------------------------- 1 | const smallWords = 2 | /^(a|an|and|as|at|but|by|en|for|if|in|nor|of|on|or|per|the|to|vs?\.?|via)$/i; 3 | function toTitleCase(string) { 4 | return string 5 | .toString() 6 | .trim() 7 | .replace(/[A-Za-z0-9\u00C0-\u00FF]+[^\s-]*/g, (match, index, title) => { 8 | if ( 9 | index > 0 && 10 | index + match.length !== title.length && 11 | match.search(smallWords) > -1 && 12 | title.charAt(index - 2) !== ":" && 13 | (title.charAt(index + match.length) !== "-" || 14 | title.charAt(index - 1) === "-") && 15 | title.charAt(index - 1).search(/[^\s-]/) < 0 16 | ) { 17 | return match.toLowerCase(); 18 | } 19 | 20 | if (match.substr(1).search(/[A-Z]|\../) > -1) { 21 | return match; 22 | } 23 | 24 | return match.charAt(0).toUpperCase() + match.substr(1); 25 | }); 26 | } 27 | 28 | // See if s could be an email address. We don't want to send personal data like email. 29 | // https://support.google.com/analytics/answer/2795983?hl=en 30 | function mightBeEmail(s) { 31 | // There's no point trying to validate rfc822 fully, just look for ...@... 32 | return typeof s === "string" && s.indexOf("@") !== -1; 33 | } 34 | 35 | const redacted = "REDACTED (Potential Email Address)"; 36 | function redactEmail(string) { 37 | if (mightBeEmail(string)) { 38 | console.warn("This arg looks like an email address, redacting."); 39 | 40 | return redacted; 41 | } 42 | 43 | return string; 44 | } 45 | 46 | export default function format( 47 | s = "", 48 | titleCase = true, 49 | redactingEmail = true 50 | ) { 51 | let _str = s || ""; 52 | 53 | if (titleCase) { 54 | _str = toTitleCase(s); 55 | } 56 | 57 | if (redactingEmail) { 58 | _str = redactEmail(_str); 59 | } 60 | 61 | return _str; 62 | } 63 | -------------------------------------------------------------------------------- /src/ga4.js: -------------------------------------------------------------------------------- 1 | import gtag from "./gtag"; 2 | import format from "./format"; 3 | 4 | /* 5 | Links 6 | https://developers.google.com/gtagjs/reference/api 7 | https://developers.google.com/tag-platform/gtagjs/reference 8 | */ 9 | 10 | /** 11 | * @typedef GaOptions 12 | * @type {Object} 13 | * @property {boolean} [cookieUpdate=true] 14 | * @property {number} [cookieExpires=63072000] Default two years 15 | * @property {string} [cookieDomain="auto"] 16 | * @property {string} [cookieFlags] 17 | * @property {string} [userId] 18 | * @property {string} [clientId] 19 | * @property {boolean} [anonymizeIp] 20 | * @property {string} [contentGroup1] 21 | * @property {string} [contentGroup2] 22 | * @property {string} [contentGroup3] 23 | * @property {string} [contentGroup4] 24 | * @property {string} [contentGroup5] 25 | * @property {boolean} [allowAdFeatures=true] 26 | * @property {boolean} [allowAdPersonalizationSignals] 27 | * @property {boolean} [nonInteraction] 28 | * @property {string} [page] 29 | */ 30 | 31 | /** 32 | * @typedef UaEventOptions 33 | * @type {Object} 34 | * @property {string} action 35 | * @property {string} category 36 | * @property {string} [label] 37 | * @property {number} [value] 38 | * @property {boolean} [nonInteraction] 39 | * @property {('beacon'|'xhr'|'image')} [transport] 40 | */ 41 | 42 | /** 43 | * @typedef InitOptions 44 | * @type {Object} 45 | * @property {string} trackingId 46 | * @property {GaOptions|any} [gaOptions] 47 | * @property {Object} [gtagOptions] New parameter 48 | */ 49 | 50 | export class GA4 { 51 | constructor() { 52 | this.reset(); 53 | } 54 | 55 | reset = () => { 56 | this.isInitialized = false; 57 | 58 | this._testMode = false; 59 | this._currentMeasurementId; 60 | this._hasLoadedGA = false; 61 | this._isQueuing = false; 62 | this._queueGtag = []; 63 | }; 64 | 65 | _gtag = (...args) => { 66 | if (!this._testMode) { 67 | if (this._isQueuing) { 68 | this._queueGtag.push(args); 69 | } else { 70 | gtag(...args); 71 | } 72 | } else { 73 | this._queueGtag.push(args); 74 | } 75 | }; 76 | 77 | gtag(...args) { 78 | this._gtag(...args); 79 | } 80 | 81 | _loadGA = ( 82 | GA_MEASUREMENT_ID, 83 | nonce, 84 | gtagUrl = "https://www.googletagmanager.com/gtag/js" 85 | ) => { 86 | if (typeof window === "undefined" || typeof document === "undefined") { 87 | return; 88 | } 89 | 90 | if (!this._hasLoadedGA) { 91 | // Global Site Tag (gtag.js) - Google Analytics 92 | const script = document.createElement("script"); 93 | script.async = true; 94 | script.src = `${gtagUrl}?id=${GA_MEASUREMENT_ID}`; 95 | if (nonce) { 96 | script.setAttribute("nonce", nonce); 97 | } 98 | document.body.appendChild(script); 99 | 100 | window.dataLayer = window.dataLayer || []; 101 | window.gtag = function gtag() { 102 | window.dataLayer.push(arguments); 103 | }; 104 | 105 | this._hasLoadedGA = true; 106 | } 107 | }; 108 | 109 | _toGtagOptions = (gaOptions) => { 110 | if (!gaOptions) { 111 | return; 112 | } 113 | 114 | const mapFields = { 115 | // Old https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#cookieUpdate 116 | // New https://developers.google.com/analytics/devguides/collection/gtagjs/cookies-user-id#cookie_update 117 | cookieUpdate: "cookie_update", 118 | cookieExpires: "cookie_expires", 119 | cookieDomain: "cookie_domain", 120 | cookieFlags: "cookie_flags", // must be in set method? 121 | userId: "user_id", 122 | clientId: "client_id", 123 | anonymizeIp: "anonymize_ip", 124 | // https://support.google.com/analytics/answer/2853546?hl=en#zippy=%2Cin-this-article 125 | contentGroup1: "content_group1", 126 | contentGroup2: "content_group2", 127 | contentGroup3: "content_group3", 128 | contentGroup4: "content_group4", 129 | contentGroup5: "content_group5", 130 | // https://support.google.com/analytics/answer/9050852?hl=en 131 | allowAdFeatures: "allow_google_signals", 132 | allowAdPersonalizationSignals: "allow_ad_personalization_signals", 133 | nonInteraction: "non_interaction", 134 | page: "page_path", 135 | hitCallback: "event_callback", 136 | }; 137 | 138 | const gtagOptions = Object.entries(gaOptions).reduce( 139 | (prev, [key, value]) => { 140 | if (mapFields[key]) { 141 | prev[mapFields[key]] = value; 142 | } else { 143 | prev[key] = value; 144 | } 145 | 146 | return prev; 147 | }, 148 | {} 149 | ); 150 | 151 | return gtagOptions; 152 | }; 153 | 154 | /** 155 | * 156 | * @param {InitOptions[]|string} GA_MEASUREMENT_ID 157 | * @param {Object} [options] 158 | * @param {string} [options.nonce] 159 | * @param {boolean} [options.testMode=false] 160 | * @param {string} [options.gtagUrl=https://www.googletagmanager.com/gtag/js] 161 | * @param {GaOptions|any} [options.gaOptions] 162 | * @param {Object} [options.gtagOptions] New parameter 163 | */ 164 | initialize = (GA_MEASUREMENT_ID, options = {}) => { 165 | if (!GA_MEASUREMENT_ID) { 166 | throw new Error("Require GA_MEASUREMENT_ID"); 167 | } 168 | 169 | const initConfigs = 170 | typeof GA_MEASUREMENT_ID === "string" 171 | ? [{ trackingId: GA_MEASUREMENT_ID }] 172 | : GA_MEASUREMENT_ID; 173 | 174 | this._currentMeasurementId = initConfigs[0].trackingId; 175 | const { 176 | gaOptions, 177 | gtagOptions, 178 | nonce, 179 | testMode = false, 180 | gtagUrl, 181 | } = options; 182 | this._testMode = testMode; 183 | 184 | if (!testMode) { 185 | this._loadGA(this._currentMeasurementId, nonce, gtagUrl); 186 | } 187 | if (!this.isInitialized) { 188 | this._gtag("js", new Date()); 189 | 190 | initConfigs.forEach((config) => { 191 | const mergedGtagOptions = { 192 | ...this._toGtagOptions({ ...gaOptions, ...config.gaOptions }), 193 | ...gtagOptions, 194 | ...config.gtagOptions, 195 | }; 196 | if (Object.keys(mergedGtagOptions).length) { 197 | this._gtag("config", config.trackingId, mergedGtagOptions); 198 | } else { 199 | this._gtag("config", config.trackingId); 200 | } 201 | }); 202 | } 203 | this.isInitialized = true; 204 | 205 | if (!testMode) { 206 | const queues = [...this._queueGtag]; 207 | this._queueGtag = []; 208 | this._isQueuing = false; 209 | while (queues.length) { 210 | const queue = queues.shift(); 211 | this._gtag(...queue); 212 | if (queue[0] === "get") { 213 | this._isQueuing = true; 214 | } 215 | } 216 | } 217 | }; 218 | 219 | set = (fieldsObject) => { 220 | if (!fieldsObject) { 221 | console.warn("`fieldsObject` is required in .set()"); 222 | 223 | return; 224 | } 225 | 226 | if (typeof fieldsObject !== "object") { 227 | console.warn("Expected `fieldsObject` arg to be an Object"); 228 | 229 | return; 230 | } 231 | 232 | if (Object.keys(fieldsObject).length === 0) { 233 | console.warn("empty `fieldsObject` given to .set()"); 234 | } 235 | 236 | this._gaCommand("set", fieldsObject); 237 | }; 238 | 239 | _gaCommandSendEvent = ( 240 | eventCategory, 241 | eventAction, 242 | eventLabel, 243 | eventValue, 244 | fieldsObject 245 | ) => { 246 | this._gtag("event", eventAction, { 247 | event_category: eventCategory, 248 | event_label: eventLabel, 249 | value: eventValue, 250 | ...(fieldsObject && { non_interaction: fieldsObject.nonInteraction }), 251 | ...this._toGtagOptions(fieldsObject), 252 | }); 253 | }; 254 | 255 | _gaCommandSendEventParameters = (...args) => { 256 | if (typeof args[0] === "string") { 257 | this._gaCommandSendEvent(...args.slice(1)); 258 | } else { 259 | const { 260 | eventCategory, 261 | eventAction, 262 | eventLabel, 263 | eventValue, 264 | // eslint-disable-next-line no-unused-vars 265 | hitType, 266 | ...rest 267 | } = args[0]; 268 | this._gaCommandSendEvent( 269 | eventCategory, 270 | eventAction, 271 | eventLabel, 272 | eventValue, 273 | rest 274 | ); 275 | } 276 | }; 277 | 278 | _gaCommandSendTiming = ( 279 | timingCategory, 280 | timingVar, 281 | timingValue, 282 | timingLabel 283 | ) => { 284 | this._gtag("event", "timing_complete", { 285 | name: timingVar, 286 | value: timingValue, 287 | event_category: timingCategory, 288 | event_label: timingLabel, 289 | }); 290 | }; 291 | 292 | _gaCommandSendPageview = (page, fieldsObject) => { 293 | if (fieldsObject && Object.keys(fieldsObject).length) { 294 | const { title, location, ...rest } = this._toGtagOptions(fieldsObject); 295 | 296 | this._gtag("event", "page_view", { 297 | ...(page && { page_path: page }), 298 | ...(title && { page_title: title }), 299 | ...(location && { page_location: location }), 300 | ...rest, 301 | }); 302 | } else if (page) { 303 | this._gtag("event", "page_view", { page_path: page }); 304 | } else { 305 | this._gtag("event", "page_view"); 306 | } 307 | }; 308 | 309 | _gaCommandSendPageviewParameters = (...args) => { 310 | if (typeof args[0] === "string") { 311 | this._gaCommandSendPageview(...args.slice(1)); 312 | } else { 313 | const { 314 | page, 315 | // eslint-disable-next-line no-unused-vars 316 | hitType, 317 | ...rest 318 | } = args[0]; 319 | this._gaCommandSendPageview(page, rest); 320 | } 321 | }; 322 | 323 | // https://developers.google.com/analytics/devguides/collection/analyticsjs/command-queue-reference#send 324 | _gaCommandSend = (...args) => { 325 | const hitType = typeof args[0] === "string" ? args[0] : args[0].hitType; 326 | 327 | switch (hitType) { 328 | case "event": 329 | this._gaCommandSendEventParameters(...args); 330 | break; 331 | case "pageview": 332 | this._gaCommandSendPageviewParameters(...args); 333 | break; 334 | case "timing": 335 | this._gaCommandSendTiming(...args.slice(1)); 336 | break; 337 | case "screenview": 338 | case "transaction": 339 | case "item": 340 | case "social": 341 | case "exception": 342 | console.warn(`Unsupported send command: ${hitType}`); 343 | break; 344 | default: 345 | console.warn(`Send command doesn't exist: ${hitType}`); 346 | } 347 | }; 348 | 349 | _gaCommandSet = (...args) => { 350 | if (typeof args[0] === "string") { 351 | args[0] = { [args[0]]: args[1] }; 352 | } 353 | this._gtag("set", this._toGtagOptions(args[0])); 354 | }; 355 | 356 | _gaCommand = (command, ...args) => { 357 | switch (command) { 358 | case "send": 359 | this._gaCommandSend(...args); 360 | break; 361 | case "set": 362 | this._gaCommandSet(...args); 363 | break; 364 | default: 365 | console.warn(`Command doesn't exist: ${command}`); 366 | } 367 | }; 368 | 369 | ga = (...args) => { 370 | if (typeof args[0] === "string") { 371 | this._gaCommand(...args); 372 | } else { 373 | const [readyCallback] = args; 374 | this._gtag("get", this._currentMeasurementId, "client_id", (clientId) => { 375 | this._isQueuing = false; 376 | const queues = this._queueGtag; 377 | 378 | readyCallback({ 379 | get: (property) => 380 | property === "clientId" 381 | ? clientId 382 | : property === "trackingId" 383 | ? this._currentMeasurementId 384 | : property === "apiVersion" 385 | ? "1" 386 | : undefined, 387 | }); 388 | 389 | while (queues.length) { 390 | const queue = queues.shift(); 391 | this._gtag(...queue); 392 | } 393 | }); 394 | 395 | this._isQueuing = true; 396 | } 397 | 398 | return this.ga; 399 | }; 400 | 401 | /** 402 | * @param {UaEventOptions|string} optionsOrName 403 | * @param {Object} [params] 404 | */ 405 | event = (optionsOrName, params) => { 406 | if (typeof optionsOrName === "string") { 407 | this._gtag("event", optionsOrName, this._toGtagOptions(params)); 408 | } else { 409 | const { action, category, label, value, nonInteraction, transport } = 410 | optionsOrName; 411 | if (!category || !action) { 412 | console.warn("args.category AND args.action are required in event()"); 413 | 414 | return; 415 | } 416 | 417 | // Required Fields 418 | const fieldObject = { 419 | hitType: "event", 420 | eventCategory: format(category), 421 | eventAction: format(action), 422 | }; 423 | 424 | // Optional Fields 425 | if (label) { 426 | fieldObject.eventLabel = format(label); 427 | } 428 | 429 | if (typeof value !== "undefined") { 430 | if (typeof value !== "number") { 431 | console.warn("Expected `args.value` arg to be a Number."); 432 | } else { 433 | fieldObject.eventValue = value; 434 | } 435 | } 436 | 437 | if (typeof nonInteraction !== "undefined") { 438 | if (typeof nonInteraction !== "boolean") { 439 | console.warn("`args.nonInteraction` must be a boolean."); 440 | } else { 441 | fieldObject.nonInteraction = nonInteraction; 442 | } 443 | } 444 | 445 | if (typeof transport !== "undefined") { 446 | if (typeof transport !== "string") { 447 | console.warn("`args.transport` must be a string."); 448 | } else { 449 | if (["beacon", "xhr", "image"].indexOf(transport) === -1) { 450 | console.warn( 451 | "`args.transport` must be either one of these values: `beacon`, `xhr` or `image`" 452 | ); 453 | } 454 | 455 | fieldObject.transport = transport; 456 | } 457 | } 458 | 459 | this._gaCommand("send", fieldObject); 460 | } 461 | }; 462 | 463 | send = (fieldObject) => { 464 | this._gaCommand("send", fieldObject); 465 | }; 466 | } 467 | 468 | export default new GA4(); 469 | -------------------------------------------------------------------------------- /src/ga4.test.js: -------------------------------------------------------------------------------- 1 | import gtag from "./gtag"; 2 | import GA4 from "./ga4"; 3 | 4 | const newDate = new Date("2020-01-01"); 5 | jest.mock("./gtag"); 6 | jest.useFakeTimers("modern").setSystemTime(newDate.getTime()); 7 | 8 | describe("GA4", () => { 9 | // Given 10 | const GA_MEASUREMENT_ID = "GA_MEASUREMENT_ID"; 11 | 12 | beforeEach(() => { 13 | gtag.mockReset(); 14 | GA4.reset(); 15 | }); 16 | 17 | describe("GA4.initialize()", () => { 18 | it("initialize() default", () => { 19 | // When 20 | GA4.initialize(GA_MEASUREMENT_ID); 21 | 22 | // Then 23 | expect(gtag).toHaveBeenNthCalledWith(1, "js", newDate); 24 | expect(gtag).toHaveBeenNthCalledWith(2, "config", GA_MEASUREMENT_ID); 25 | expect(gtag).toHaveBeenCalledTimes(2); 26 | }); 27 | 28 | it("initialize() with options", () => { 29 | // Given 30 | const options = { 31 | gaOptions: { 32 | cookieUpdate: false, 33 | }, 34 | }; 35 | 36 | // When 37 | GA4.initialize(GA_MEASUREMENT_ID, options); 38 | 39 | // Then 40 | expect(gtag).toHaveBeenNthCalledWith(1, "js", newDate); 41 | expect(gtag).toHaveBeenNthCalledWith(2, "config", GA_MEASUREMENT_ID, { 42 | cookie_update: false, 43 | }); 44 | expect(gtag).toHaveBeenCalledTimes(2); 45 | }); 46 | 47 | it("initialize() in test mode", () => { 48 | // Given 49 | const options = { 50 | testMode: true, 51 | }; 52 | const command = "send"; 53 | const object = { hitType: "pageview" }; 54 | 55 | // When 56 | GA4.initialize(GA_MEASUREMENT_ID, options); 57 | GA4.ga(command, object); 58 | 59 | // Then 60 | expect(gtag).toHaveBeenCalledTimes(0); 61 | }); 62 | 63 | it("initialize() multiple products", () => { 64 | // Given 65 | const GA_MEASUREMENT_ID2 = "GA_MEASUREMENT_ID2"; 66 | const config = [ 67 | { trackingId: GA_MEASUREMENT_ID }, 68 | { trackingId: GA_MEASUREMENT_ID2 }, 69 | ]; 70 | 71 | // When 72 | GA4.initialize(config); 73 | 74 | // Then 75 | expect(gtag).toHaveBeenNthCalledWith(1, "js", newDate); 76 | expect(gtag).toHaveBeenNthCalledWith(2, "config", GA_MEASUREMENT_ID); 77 | expect(gtag).toHaveBeenNthCalledWith(3, "config", GA_MEASUREMENT_ID2); 78 | expect(gtag).toHaveBeenCalledTimes(3); 79 | }); 80 | }); 81 | 82 | describe("GA4.ga()", () => { 83 | it("ga() send pageview", () => { 84 | // Given 85 | const command = "send"; 86 | const object = { hitType: "pageview" }; 87 | 88 | // When 89 | GA4.ga(command, object); 90 | 91 | // Then 92 | expect(gtag).toHaveBeenNthCalledWith(1, "event", "page_view"); 93 | }); 94 | 95 | it("ga() send timing", () => { 96 | // Given 97 | const command = "send"; 98 | const hitType = "timing"; 99 | const timingCategory = "DOM"; 100 | const timingVar = "first-contentful-paint"; 101 | const timingValue = 120; 102 | 103 | // When 104 | GA4.ga(command, hitType, timingCategory, timingVar, timingValue); 105 | 106 | // Then 107 | expect(gtag).toHaveBeenNthCalledWith(1, "event", "timing_complete", { 108 | event_category: timingCategory, 109 | name: timingVar, 110 | value: timingValue, 111 | }); 112 | }); 113 | 114 | it("ga() callback", (done) => { 115 | // Given 116 | const clientId = "clientId value"; 117 | gtag.mockImplementationOnce((command, target, field_name, cb) => 118 | cb(clientId) 119 | ); 120 | 121 | const callback = jest.fn((tracker) => { 122 | const trackerClientId = tracker.get("clientId"); 123 | const trackerTrackingId = tracker.get("trackingId"); 124 | const trackerApiVersion = tracker.get("apiVersion"); 125 | expect(trackerClientId).toEqual(clientId); 126 | expect(trackerTrackingId).toEqual(GA_MEASUREMENT_ID); 127 | expect(trackerApiVersion).toEqual("1"); 128 | done(); 129 | }); 130 | 131 | // When 132 | GA4.ga(callback); 133 | 134 | // Then 135 | expect(gtag).toHaveBeenNthCalledWith( 136 | 1, 137 | "get", 138 | GA_MEASUREMENT_ID, 139 | "client_id", 140 | expect.any(Function) 141 | ); 142 | }); 143 | 144 | it("ga() async callback", (done) => { 145 | // Given 146 | const clientId = "clientId value"; 147 | gtag.mockImplementationOnce((command, target, field_name, cb) => 148 | cb(clientId) 149 | ); 150 | 151 | const callback = jest.fn(async (tracker) => { 152 | const trackerClientId = tracker.get("clientId"); 153 | expect(trackerClientId).toEqual(clientId); 154 | done(); 155 | }); 156 | 157 | // When 158 | GA4.ga(callback); 159 | 160 | // Then 161 | expect(gtag).toHaveBeenNthCalledWith( 162 | 1, 163 | "get", 164 | GA_MEASUREMENT_ID, 165 | "client_id", 166 | expect.any(Function) 167 | ); 168 | }); 169 | 170 | it("ga() callback queue", (done) => { 171 | // Given 172 | const clientId = "clientId value"; 173 | gtag.mockImplementationOnce((command, target, field_name, cb) => { 174 | setImmediate(() => cb(clientId)); 175 | }); 176 | 177 | const callback = jest.fn(() => { 178 | GA4.ga("send", { hitType: "pageview" }); 179 | expect(gtag).toHaveBeenNthCalledWith(2, "event", "page_view"); 180 | done(); 181 | }); 182 | 183 | // When 184 | GA4.ga(callback); 185 | GA4.ga("send", "event", "category value"); 186 | 187 | // Then 188 | expect(gtag).toHaveBeenNthCalledWith( 189 | 1, 190 | "get", 191 | GA_MEASUREMENT_ID, 192 | "client_id", 193 | expect.any(Function) 194 | ); 195 | expect(gtag).toHaveBeenCalledTimes(1); 196 | expect(GA4._isQueuing).toBeTruthy(); 197 | expect(GA4._queueGtag).toHaveLength(1); 198 | 199 | jest.runAllTimers(); 200 | 201 | expect(GA4._isQueuing).toBeFalsy(); 202 | expect(GA4._queueGtag).toHaveLength(0); 203 | expect(gtag).toHaveBeenNthCalledWith(3, "event", undefined, { 204 | event_category: "category value", 205 | }); 206 | }); 207 | }); 208 | 209 | describe("GA4.send()", () => { 210 | it("send() pageview", () => { 211 | // Given 212 | const object = { hitType: "pageview" }; 213 | 214 | // When 215 | GA4.send(object); 216 | 217 | // Then 218 | expect(gtag).toHaveBeenNthCalledWith(1, "event", "page_view"); 219 | }); 220 | }); 221 | 222 | describe("GA4.event()", () => { 223 | it("event() custom events", () => { 224 | // Given 225 | const eventName = "screen_view"; 226 | const eventParams = { 227 | app_name: "myAppName", 228 | screen_name: "Home", 229 | }; 230 | 231 | // When 232 | GA4.event(eventName, eventParams); 233 | 234 | // Then 235 | expect(gtag).toHaveBeenNthCalledWith(1, "event", eventName, eventParams); 236 | }); 237 | 238 | it("event() simple", () => { 239 | // Given 240 | const object = { 241 | category: "category value", 242 | action: "action value", 243 | label: "label value", 244 | nonInteraction: true, 245 | }; 246 | 247 | // When 248 | GA4.event(object); 249 | 250 | // Then 251 | expect(gtag).toHaveBeenNthCalledWith(1, "event", "Action Value", { 252 | event_category: "Category Value", 253 | event_label: "Label Value", 254 | non_interaction: true, 255 | }); 256 | }); 257 | }); 258 | 259 | describe("GA4.set()", () => { 260 | it("set()", () => { 261 | // Given 262 | const object = { 263 | anonymizeIp: true, 264 | referrer: "/signup", 265 | allowAdFeatures: "allowAdFeatures value", 266 | allowAdPersonalizationSignals: "allowAdPersonalizationSignals value", 267 | page: "/home", 268 | }; 269 | 270 | // When 271 | GA4.set(object); 272 | 273 | // Then 274 | expect(gtag).toHaveBeenNthCalledWith(1, "set", { 275 | anonymize_ip: true, 276 | referrer: "/signup", 277 | allow_google_signals: "allowAdFeatures value", 278 | allow_ad_personalization_signals: "allowAdPersonalizationSignals value", 279 | page_path: "/home", 280 | }); 281 | }); 282 | }); 283 | 284 | describe("Reference", () => { 285 | it("pageview", () => { 286 | // Old https://developers.google.com/analytics/devguides/collection/analyticsjs/pages 287 | // New https://developers.google.com/gtagjs/reference/event#page_view 288 | 289 | // Given 290 | const hitType = "pageview"; 291 | const path = "/location-pathname"; 292 | const title = "title value"; 293 | 294 | // When / Then 295 | 296 | // Without parameters 297 | GA4.send(hitType); 298 | expect(gtag).toHaveBeenNthCalledWith(1, "event", "page_view"); 299 | GA4.send({ hitType }); 300 | expect(gtag).toHaveBeenNthCalledWith(2, "event", "page_view"); 301 | GA4.ga("send", hitType); 302 | expect(gtag).toHaveBeenNthCalledWith(3, "event", "page_view"); 303 | 304 | // With path parameter 305 | GA4.send({ hitType, page: path }); 306 | expect(gtag).toHaveBeenNthCalledWith(4, "event", "page_view", { 307 | page_path: path, 308 | }); 309 | GA4.ga("send", hitType, path); 310 | expect(gtag).toHaveBeenNthCalledWith(5, "event", "page_view", { 311 | page_path: path, 312 | }); 313 | 314 | // With path and title parameter 315 | GA4.send({ hitType, page: path, title }); 316 | expect(gtag).toHaveBeenNthCalledWith(6, "event", "page_view", { 317 | page_path: path, 318 | page_title: title, 319 | }); 320 | GA4.ga("send", hitType, path, { title }); 321 | expect(gtag).toHaveBeenNthCalledWith(7, "event", "page_view", { 322 | page_path: path, 323 | page_title: title, 324 | }); 325 | }); 326 | }); 327 | 328 | describe("Web vitals", () => { 329 | it("Web vitals", () => { 330 | // https://github.com/GoogleChrome/web-vitals/blob/main/README.md 331 | function sendToGoogleAnalytics({ name, delta, value, id }) { 332 | GA4.send({ 333 | hitType: "event", 334 | eventCategory: "Web Vitals", 335 | eventAction: name, 336 | eventLabel: id, 337 | nonInteraction: true, 338 | // Built-in params: 339 | value: Math.round(name === "CLS" ? delta * 1000 : delta), // Use `delta` so the value can be summed. 340 | // Custom params: 341 | metric_id: id, // Needed to aggregate events. 342 | metric_value: value, // Optional. 343 | metric_delta: delta, // Optional. 344 | 345 | // OPTIONAL: any additional params or debug info here. 346 | // See: https://web.dev/debug-web-vitals-in-the-field/ 347 | // metric_rating: 'good' | 'ni' | 'poor', 348 | // debug_info: '...', 349 | // ... 350 | }); 351 | } 352 | 353 | sendToGoogleAnalytics({ 354 | name: "CLS", 355 | delta: 12.34, 356 | value: 1, 357 | id: "v2-1632380328370-6426221164013", 358 | }); 359 | 360 | expect(gtag).toHaveBeenNthCalledWith(1, "event", "CLS", { 361 | event_category: "Web Vitals", 362 | event_label: "v2-1632380328370-6426221164013", 363 | metric_delta: 12.34, 364 | metric_id: "v2-1632380328370-6426221164013", 365 | metric_value: 1, 366 | non_interaction: true, 367 | value: 12340, 368 | }); 369 | }); 370 | }); 371 | }); 372 | -------------------------------------------------------------------------------- /src/gtag.js: -------------------------------------------------------------------------------- 1 | const gtag = (...args) => { 2 | if (typeof window !== "undefined") { 3 | if (typeof window.gtag === "undefined") { 4 | window.dataLayer = window.dataLayer || []; 5 | window.gtag = function gtag() { 6 | window.dataLayer.push(arguments); 7 | }; 8 | } 9 | 10 | window.gtag(...args); 11 | } 12 | }; 13 | 14 | export default gtag; 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ga4, { GA4 } from "./ga4"; 2 | 3 | export const ReactGAImplementation = GA4; 4 | 5 | export default ga4; 6 | -------------------------------------------------------------------------------- /types/format.d.ts: -------------------------------------------------------------------------------- 1 | export default function format(s?: string, titleCase?: boolean, redactingEmail?: boolean): string; 2 | -------------------------------------------------------------------------------- /types/ga4.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef GaOptions 3 | * @type {Object} 4 | * @property {boolean} [cookieUpdate=true] 5 | * @property {number} [cookieExpires=63072000] Default two years 6 | * @property {string} [cookieDomain="auto"] 7 | * @property {string} [cookieFlags] 8 | * @property {string} [userId] 9 | * @property {string} [clientId] 10 | * @property {boolean} [anonymizeIp] 11 | * @property {string} [contentGroup1] 12 | * @property {string} [contentGroup2] 13 | * @property {string} [contentGroup3] 14 | * @property {string} [contentGroup4] 15 | * @property {string} [contentGroup5] 16 | * @property {boolean} [allowAdFeatures=true] 17 | * @property {boolean} [allowAdPersonalizationSignals] 18 | * @property {boolean} [nonInteraction] 19 | * @property {string} [page] 20 | */ 21 | /** 22 | * @typedef UaEventOptions 23 | * @type {Object} 24 | * @property {string} action 25 | * @property {string} category 26 | * @property {string} [label] 27 | * @property {number} [value] 28 | * @property {boolean} [nonInteraction] 29 | * @property {('beacon'|'xhr'|'image')} [transport] 30 | */ 31 | /** 32 | * @typedef InitOptions 33 | * @type {Object} 34 | * @property {string} trackingId 35 | * @property {GaOptions|any} [gaOptions] 36 | * @property {Object} [gtagOptions] New parameter 37 | */ 38 | export class GA4 { 39 | reset: () => void; 40 | isInitialized: boolean; 41 | _testMode: boolean; 42 | _hasLoadedGA: boolean; 43 | _isQueuing: boolean; 44 | _queueGtag: any[]; 45 | _gtag: (...args: any[]) => void; 46 | gtag(...args: any[]): void; 47 | _loadGA: (GA_MEASUREMENT_ID: any, nonce: any, gtagUrl?: string) => void; 48 | _toGtagOptions: (gaOptions: any) => {}; 49 | /** 50 | * 51 | * @param {InitOptions[]|string} GA_MEASUREMENT_ID 52 | * @param {Object} [options] 53 | * @param {string} [options.nonce] 54 | * @param {boolean} [options.testMode=false] 55 | * @param {string} [options.gtagUrl=https://www.googletagmanager.com/gtag/js] 56 | * @param {GaOptions|any} [options.gaOptions] 57 | * @param {Object} [options.gtagOptions] New parameter 58 | */ 59 | initialize: (GA_MEASUREMENT_ID: InitOptions[] | string, options?: { 60 | nonce?: string; 61 | testMode?: boolean; 62 | gtagUrl?: string; 63 | gaOptions?: GaOptions | any; 64 | gtagOptions?: any; 65 | }) => void; 66 | _currentMeasurementId: string; 67 | set: (fieldsObject: any) => void; 68 | _gaCommandSendEvent: (eventCategory: any, eventAction: any, eventLabel: any, eventValue: any, fieldsObject: any) => void; 69 | _gaCommandSendEventParameters: (...args: any[]) => void; 70 | _gaCommandSendTiming: (timingCategory: any, timingVar: any, timingValue: any, timingLabel: any) => void; 71 | _gaCommandSendPageview: (page: any, fieldsObject: any) => void; 72 | _gaCommandSendPageviewParameters: (...args: any[]) => void; 73 | _gaCommandSend: (...args: any[]) => void; 74 | _gaCommandSet: (...args: any[]) => void; 75 | _gaCommand: (command: any, ...args: any[]) => void; 76 | ga: (...args: any[]) => any; 77 | /** 78 | * @param {UaEventOptions|string} optionsOrName 79 | * @param {Object} [params] 80 | */ 81 | event: (optionsOrName: UaEventOptions | string, params?: any) => void; 82 | send: (fieldObject: any) => void; 83 | } 84 | declare const _default: GA4; 85 | export default _default; 86 | export type GaOptions = { 87 | cookieUpdate?: boolean; 88 | /** 89 | * Default two years 90 | */ 91 | cookieExpires?: number; 92 | cookieDomain?: string; 93 | cookieFlags?: string; 94 | userId?: string; 95 | clientId?: string; 96 | anonymizeIp?: boolean; 97 | contentGroup1?: string; 98 | contentGroup2?: string; 99 | contentGroup3?: string; 100 | contentGroup4?: string; 101 | contentGroup5?: string; 102 | allowAdFeatures?: boolean; 103 | allowAdPersonalizationSignals?: boolean; 104 | nonInteraction?: boolean; 105 | page?: string; 106 | }; 107 | export type UaEventOptions = { 108 | action: string; 109 | category: string; 110 | label?: string; 111 | value?: number; 112 | nonInteraction?: boolean; 113 | transport?: ('beacon' | 'xhr' | 'image'); 114 | }; 115 | export type InitOptions = { 116 | trackingId: string; 117 | gaOptions?: GaOptions | any; 118 | /** 119 | * New parameter 120 | */ 121 | gtagOptions?: any; 122 | }; 123 | -------------------------------------------------------------------------------- /types/gtag.d.ts: -------------------------------------------------------------------------------- 1 | export default gtag; 2 | declare function gtag(...args: any[]): void; 3 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export const ReactGAImplementation: typeof GA4; 2 | export default ga4; 3 | import { GA4 } from "./ga4"; 4 | import ga4 from "./ga4"; 5 | --------------------------------------------------------------------------------