├── .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 |
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 |
--------------------------------------------------------------------------------