├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .npmignore ├── .prettierrc ├── FUNDING.yml ├── LICENSE ├── README.md ├── dist ├── ga4mp.amd.js ├── ga4mp.amd.min.js ├── ga4mp.esm.js ├── ga4mp.esm.min.js ├── ga4mp.iife.js ├── ga4mp.iife.min.js ├── ga4mp.umd.js └── ga4mp.umd.min.js ├── docs ├── .nojekyll ├── .vscode │ └── settings.json ├── README.md ├── api.md ├── assets │ ├── docsify-themeable.min.js │ ├── docsify.min.js │ ├── search.js │ ├── theme-simple-dark.css │ ├── theme-simple.css │ └── twitter-card.jpg ├── campaigns.md ├── coverpage.md ├── demo.html ├── features.md ├── index.html ├── installation.md ├── markdown.md ├── options.md ├── playground │ └── index.html ├── ports.md ├── sidebar.md └── tracking.md ├── index.html ├── jest.config.js ├── package.json ├── rollup.config.js └── src ├── ga4mp.js ├── index.js └── modules ├── clientHints.js ├── ga4Schema.js ├── helpers.js ├── pageInfo.js └── request.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["node_modules/**"], 3 | "plugins": ["@babel/plugin-transform-object-assign"], 4 | "presets": [["@babel/env", { "modules": false }]], 5 | "env": { 6 | "test": { 7 | "presets": ["@babel/preset-env"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/**/*.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | "sourceType": "module" 5 | }, 6 | "env": { 7 | "browser": true, 8 | "jest": true 9 | }, 10 | "rules": { 11 | "quotes": [2, "double"], 12 | "block-scoped-var": 1, 13 | "class-methods-use-this": 1, 14 | "complexity": 1, 15 | "consistent-return": 1, 16 | "curly": 2, 17 | "default-case": 1, 18 | "dot-location": 1, 19 | "dot-notation": 1, 20 | "eqeqeq": 2, 21 | "guard-for-in": 1, 22 | "no-alert": 1, 23 | "no-caller": 1, 24 | "no-case-declarations": 1, 25 | "no-div-regex": 1, 26 | "no-else-return": 1, 27 | "no-empty-function": 1, 28 | "no-empty-pattern": 1, 29 | "no-eq-null": 1, 30 | "no-eval": 1, 31 | "no-extend-native": 1, 32 | "no-extra-bind": 1, 33 | "no-extra-label": 1, 34 | "no-fallthrough": 1, 35 | "no-floating-decimal": 1, 36 | "no-global-assign": 1, 37 | "no-implicit-coercion": 0, 38 | "no-implicit-globals": 1, 39 | "no-implied-eval": 1, 40 | "no-invalid-this": 1, 41 | "no-iterator": 1, 42 | "no-labels": 1, 43 | "no-lone-blocks": 1, 44 | "no-loop-func": 1, 45 | "no-magic-numbers": [1, { "ignore": [-1, 0, 1] }], 46 | "no-multi-spaces": 1, 47 | "no-multi-str": 1, 48 | "no-new": 1, 49 | "no-new-func": 1, 50 | "no-new-wrappers": 1, 51 | "no-octal": 1, 52 | "no-octal-escape": 1, 53 | "no-param-reassign": 1, 54 | "no-proto": 1, 55 | "no-redeclare": 1, 56 | "no-restricted-properties": 1, 57 | "no-return-assign": 1, 58 | "no-return-await": 1, 59 | "no-script-url": 1, 60 | "no-self-assign": 1, 61 | "no-self-compare": 1, 62 | "no-sequences": 1, 63 | "no-throw-literal": 1, 64 | "no-unmodified-loop-condition": 1, 65 | "no-unused-expressions": 1, 66 | "no-unused-labels": 1, 67 | "no-useless-call": 1, 68 | "no-useless-concat": 1, 69 | "no-useless-escape": 1, 70 | "no-useless-return": 1, 71 | "no-void": 1, 72 | "no-warning-comments": 1, 73 | "no-with": 1, 74 | "prefer-promise-reject-errors": 1, 75 | "radix": 1, 76 | "require-await": 1, 77 | "vars-on-top": 1, 78 | "wrap-iife": 1, 79 | "yoda": 1 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | 5 | # Folder config file 6 | Desktop.ini 7 | 8 | # Recycle Bin used on file shares 9 | $RECYCLE.BIN/ 10 | 11 | # Windows Installer files 12 | *.cab 13 | *.msi 14 | *.msm 15 | *.msp 16 | 17 | # ========================= 18 | # Operating System Files 19 | # ========================= 20 | 21 | # OSX 22 | # ========================= 23 | 24 | .DS_Store 25 | .AppleDouble 26 | .LSOverride 27 | 28 | # Icon must end with two \r 29 | Icon 30 | 31 | 32 | # Thumbnails 33 | ._* 34 | 35 | # Files that might appear on external disk 36 | .Spotlight-V100 37 | .Trashes 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | # Editor files 47 | .idea 48 | 49 | # Node modules 50 | node_modules 51 | 52 | # Jekyll! 53 | _site 54 | .sass-cache 55 | 56 | demo 57 | .github 58 | yarn.lock 59 | package-lock.json 60 | internal.html -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | 33 | # Dependency directories 34 | node_modules/ 35 | jspm_packages/ 36 | 37 | # Typescript v1 declaration files 38 | typings/ 39 | 40 | # Optional npm cache directory 41 | .npm 42 | 43 | # Optional eslint cache 44 | .eslintcache 45 | 46 | # Optional REPL history 47 | .node_repl_history 48 | 49 | # Output of 'npm pack' 50 | *.tgz 51 | 52 | # Yarn Integrity file 53 | .yarn-integrity 54 | 55 | # dotenv environment variables file 56 | .env 57 | 58 | package-lock.json 59 | yarn.lock 60 | index.html 61 | 62 | docs/ 63 | index.html 64 | 65 | demo 66 | .github 67 | yarn.lock 68 | package-lock.json 69 | internal.html -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "printWidth": 100, 5 | "trailingComma": "none", 6 | "overrides": [ 7 | { 8 | "files": "*.js", 9 | "options": { 10 | "tabWidth": 4 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: thyngster 2 | ko_fi: thyngster -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 David Vallejo . Analytics Debugger S.L.U. 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # GA4MP - Google Analytics 4 Measurement Protocol 3 | 4 | This is an open-source implementation for the *client-side* protocol used by **Google Analytics 4**. When I mention "**client-side**" is because it must be differentiated with the official [GA4 Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/ga4) offered by Google. 5 | 6 | This library implements the public **Google Analytics 4** protocol to make possible to do a full server-side tracking using NODE/JS which is not actually possible with the official **Measurement Protocol** , which is meant only to augment the current GA4 data and it's not ready for doing a full tracking. 7 | 8 | Main differences with the official offerser server-side protocol are: 9 | 10 | - Trigger new sessions and visits starts 11 | - Track Sessions attribution 12 | - Override the User IP to populate the GEO Details 13 | - View the hits on the DebugView 14 | - Override ANY value you want 15 | - Easily portable to other languages 16 | 17 | If we compare this library with the official GTAG implementation. 18 | 19 | - Lightweight 8.7kb (<4kb compressed ) 95.6% Lighter than a GTAG Container 20 | ![image](https://user-images.githubusercontent.com/1494564/201500771-f54c592b-4f37-4ac1-9a89-87878987cc33.png) 21 | - Privacy Compliant: Full control over which cookies are created/read and sent to Google 22 | - Dual Tracking (send hits to multiple measurement Ids) 23 | 24 | ## Usage 25 | 26 | #### **ES6 Imports** 27 | 28 | import ga4mp from '@analytics-debugger/ga4mp' 29 | const ga4track = ga4mp(["G-THYNGSTER"], { 30 | user_id: undefined, 31 | non_personalized_ads: true, 32 | debug: true 33 | }); 34 | 35 | #### **Browser** 36 | <.script src="https://cdn.jsdelivr.net/npm/@analytics-debugger/ga4mp@latest/dist/ga4mp.umd.min.js">< /script> 37 | 38 | const ga4track = ga4mp(["G-THYNGSTER"], { 39 | user_id: undefined, 40 | non_personalized_ads: true, 41 | debug: true 42 | }); 43 | 44 | #### **NODE.JS** 45 | 46 | const ga4mp = require('./dist/ga4mp') 47 | const ga4track = ga4mp(["G-THYNGSTER"], { 48 | user_id: undefined, 49 | non_personalized_ads: true, 50 | debug: true 51 | }); 52 | 53 | # More API details 54 | Read more at: https://ga4mp.dev/ 55 | -------------------------------------------------------------------------------- /dist/ga4mp.amd.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * @analytics-debugger/ga4mp 0.0.8 4 | * https://github.com/analytics-debugger/ga4mp 5 | * 6 | * Copyright (c) David Vallejo (https://www.thyngster.com). 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE file in the root directory of this source tree. 9 | * 10 | */ 11 | 12 | define((function () { 'use strict'; 13 | 14 | function _extends() { 15 | _extends = Object.assign ? Object.assign.bind() : function (target) { 16 | for (var i = 1; i < arguments.length; i++) { 17 | var source = arguments[i]; 18 | for (var key in source) { 19 | if (Object.prototype.hasOwnProperty.call(source, key)) { 20 | target[key] = source[key]; 21 | } 22 | } 23 | } 24 | return target; 25 | }; 26 | return _extends.apply(this, arguments); 27 | } 28 | 29 | var trim = function trim(str, chars) { 30 | if (typeof str === 'string') { 31 | return str.substring(0, chars); 32 | } else { 33 | return str; 34 | } 35 | }; 36 | var isFunction = function isFunction(val) { 37 | if (!val) return false; 38 | return Object.prototype.toString.call(val) === '[object Function]' || typeof val === 'function' && Object.prototype.toString.call(val) !== '[object RegExp]'; 39 | }; 40 | var isNumber = function isNumber(val) { 41 | return 'number' === typeof val && !isNaN(val); 42 | }; 43 | var isString = function isString(val) { 44 | return val != null && typeof val === 'string'; 45 | }; 46 | var randomInt = function randomInt() { 47 | return Math.floor(Math.random() * (2147483647 - 0 + 1) + 0); 48 | }; 49 | var timestampInSeconds = function timestampInSeconds() { 50 | return Math.floor(new Date() * 1 / 1000); 51 | }; 52 | var getEnvironment = function getEnvironment() { 53 | var env; 54 | if (typeof window !== 'undefined' && typeof window.document !== 'undefined') env = 'browser';else if (typeof process !== 'undefined' && process.versions != null && process.versions.node != null) env = 'node'; 55 | return env; 56 | }; 57 | 58 | /** 59 | * Function to sanitize values based on GA4 Model Limits 60 | * @param {string} val 61 | * @param {integer} maxLength 62 | * @returns 63 | */ 64 | 65 | var sanitizeValue = function sanitizeValue(val, maxLength) { 66 | // Trim a key-value pair value based on GA4 limits 67 | /*eslint-disable */ 68 | try { 69 | val = val.toString(); 70 | } catch (e) {} 71 | /*eslint-enable */ 72 | if (!isString(val) || !maxLength || !isNumber(maxLength)) return val; 73 | return trim(val, maxLength); 74 | }; 75 | 76 | var ga4Schema = { 77 | _em: 'em', 78 | event_name: 'en', 79 | protocol_version: 'v', 80 | _page_id: '_p', 81 | _is_debug: '_dbg', 82 | tracking_id: 'tid', 83 | hit_count: '_s', 84 | user_id: 'uid', 85 | client_id: 'cid', 86 | page_location: 'dl', 87 | language: 'ul', 88 | firebase_id: '_fid', 89 | traffic_type: 'tt', 90 | ignore_referrer: 'ir', 91 | screen_resolution: 'sr', 92 | global_developer_id_string: 'gdid', 93 | redact_device_info: '_rdi', 94 | geo_granularity: '_geo', 95 | _is_passthrough_cid: 'gtm_up', 96 | _is_linker_valid: '_glv', 97 | _user_agent_architecture: 'uaa', 98 | _user_agent_bitness: 'uab', 99 | _user_agent_full_version_list: 'uafvl', 100 | _user_agent_mobile: 'uamb', 101 | _user_agent_model: 'uam', 102 | _user_agent_platform: 'uap', 103 | _user_agent_platform_version: 'uapv', 104 | _user_agent_wait: 'uaW', 105 | _user_agent_wow64: 'uaw', 106 | error_code: 'ec', 107 | session_id: 'sid', 108 | session_number: 'sct', 109 | session_engaged: 'seg', 110 | page_referrer: 'dr', 111 | page_title: 'dt', 112 | currency: 'cu', 113 | campaign_content: 'cc', 114 | campaign_id: 'ci', 115 | campaign_medium: 'cm', 116 | campaign_name: 'cn', 117 | campaign_source: 'cs', 118 | campaign_term: 'ck', 119 | engagement_time_msec: '_et', 120 | event_developer_id_string: 'edid', 121 | is_first_visit: '_fv', 122 | is_new_to_site: '_nsi', 123 | is_session_start: '_ss', 124 | is_conversion: '_c', 125 | euid_mode_enabled: 'ecid', 126 | non_personalized_ads: '_npa', 127 | create_google_join: 'gaz', 128 | is_consent_update: 'gsu', 129 | user_ip_address: '_uip', 130 | google_consent_state: 'gcs', 131 | google_consent_update: 'gcu', 132 | us_privacy_string: 'us_privacy', 133 | document_location: 'dl', 134 | document_path: 'dp', 135 | document_title: 'dt', 136 | document_referrer: 'dr', 137 | user_language: 'ul', 138 | document_hostname: 'dh', 139 | item_id: 'id', 140 | item_name: 'nm', 141 | item_brand: 'br', 142 | item_category: 'ca', 143 | item_category2: 'c2', 144 | item_category3: 'c3', 145 | item_category4: 'c4', 146 | item_category5: 'c5', 147 | item_variant: 'va', 148 | price: 'pr', 149 | quantity: 'qt', 150 | coupon: 'cp', 151 | item_list_name: 'ln', 152 | index: 'lp', 153 | item_list_id: 'li', 154 | discount: 'ds', 155 | affiliation: 'af', 156 | promotion_id: 'pi', 157 | promotion_name: 'pn', 158 | creative_name: 'cn', 159 | creative_slot: 'cs', 160 | location_id: 'lo', 161 | // legacy ecommerce 162 | id: 'id', 163 | name: 'nm', 164 | brand: 'br', 165 | variant: 'va', 166 | list_name: 'ln', 167 | list_position: 'lp', 168 | list: 'ln', 169 | position: 'lp', 170 | creative: 'cn' 171 | }; 172 | var ecommerceEvents = ['add_payment_info', 'add_shipping_info', 'add_to_cart', 'remove_from_cart', 'view_cart', 'begin_checkout', 'select_item', 'view_item_list', 'select_promotion', 'view_promotion', 'purchase', 'refund', 'view_item', 'add_to_wishlist']; 173 | 174 | var sendRequest = function sendRequest(endpoint, payload) { 175 | var mode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'browser'; 176 | var opts = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; 177 | var qs = new URLSearchParams(JSON.parse(JSON.stringify(payload))).toString(); 178 | if (mode === 'browser') { 179 | var _navigator; 180 | (_navigator = navigator) === null || _navigator === void 0 ? void 0 : _navigator.sendBeacon([endpoint, qs].join('?')); 181 | } else { 182 | var scheme = endpoint.split('://')[0]; 183 | var req = require("".concat(scheme)); 184 | var options = { 185 | headers: { 186 | 'User-Agent': opts.user_agent 187 | }, 188 | timeout: 500 189 | }; 190 | var request = req.get([endpoint, qs].join('?'), options, function (resp) { 191 | resp.on('data', function (chunk) { 192 | }); 193 | resp.on('end', function () { 194 | // TO-DO Handle Server Side Responses 195 | }); 196 | }).on('error', function (err) { 197 | console.log('Error: ' + err.message); 198 | }); 199 | request.on('timeout', function () { 200 | request.destroy(); 201 | }); 202 | } 203 | }; 204 | 205 | var clientHints = function clientHints(mode) { 206 | var _navigator, _navigator$userAgentD; 207 | if (mode === 'node' || typeof window === 'undefined' || typeof window !== 'undefined' && !('navigator' in window)) { 208 | return new Promise(function (resolve) { 209 | resolve(null); 210 | }); 211 | } 212 | if (!((_navigator = navigator) !== null && _navigator !== void 0 && (_navigator$userAgentD = _navigator.userAgentData) !== null && _navigator$userAgentD !== void 0 && _navigator$userAgentD.getHighEntropyValues)) return new Promise(function (resolve) { 213 | resolve(null); 214 | }); 215 | return navigator.userAgentData.getHighEntropyValues(['platform', 'platformVersion', 'architecture', 'model', 'uaFullVersion', 'bitness', 'fullVersionList', 'wow64']).then(function (d) { 216 | var _navigator2, _navigator2$userAgent, _navigator3, _navigator3$userAgent, _navigator4, _navigator4$userAgent; 217 | return { 218 | _user_agent_architecture: d.architecture, 219 | _user_agent_bitness: d.bitness, 220 | _user_agent_full_version_list: encodeURIComponent((Object.values(d.fullVersionList) || ((_navigator2 = navigator) === null || _navigator2 === void 0 ? void 0 : (_navigator2$userAgent = _navigator2.userAgentData) === null || _navigator2$userAgent === void 0 ? void 0 : _navigator2$userAgent.brands)).map(function (h) { 221 | return [h.brand, h.version].join(';'); 222 | }).join('|')), 223 | _user_agent_mobile: d.mobile ? 1 : 0, 224 | _user_agent_model: d.model || ((_navigator3 = navigator) === null || _navigator3 === void 0 ? void 0 : (_navigator3$userAgent = _navigator3.userAgentData) === null || _navigator3$userAgent === void 0 ? void 0 : _navigator3$userAgent.mobile), 225 | _user_agent_platform: d.platform || ((_navigator4 = navigator) === null || _navigator4 === void 0 ? void 0 : (_navigator4$userAgent = _navigator4.userAgentData) === null || _navigator4$userAgent === void 0 ? void 0 : _navigator4$userAgent.platform), 226 | _user_agent_platform_version: d.platformVersion, 227 | _user_agent_wow64: d.wow64 ? 1 : 0 228 | }; 229 | }); 230 | }; 231 | 232 | /** 233 | * Populate Page Related Details 234 | */ 235 | var pageDetails = function pageDetails() { 236 | return { 237 | page_location: document.location.href, 238 | page_referrer: document.referrer, 239 | page_title: document.title, 240 | language: (navigator && (navigator.language || navigator.browserLanguage) || '').toLowerCase(), 241 | screen_resolution: (window.screen ? window.screen.width : 0) + 'x' + (window.screen ? window.screen.height : 0) 242 | }; 243 | }; 244 | 245 | var version = '0.0.8'; 246 | 247 | /** 248 | * Main Class Function 249 | * @param {array|string} measurement_ids 250 | * @param {object} config 251 | * @returns 252 | */ 253 | 254 | var ga4mp = function ga4mp(measurement_ids) { 255 | var config = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 256 | if (!measurement_ids) throw 'Tracker initialization aborted: missing tracking ids'; 257 | var internalModel = _extends({ 258 | version: version, 259 | debug: false, 260 | mode: getEnvironment() || 'browser', 261 | measurement_ids: null, 262 | queueDispatchTime: 5000, 263 | queueDispatchMaxEvents: 10, 264 | queue: [], 265 | eventParameters: {}, 266 | persistentEventParameters: {}, 267 | userProperties: {}, 268 | user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(version, "]"), 269 | user_ip_address: null, 270 | hooks: { 271 | beforeLoad: function beforeLoad() {}, 272 | beforeRequestSend: function beforeRequestSend() {} 273 | }, 274 | endpoint: 'https://www.google-analytics.com/g/collect', 275 | payloadData: {} 276 | }, config); 277 | 278 | // Initialize Tracker Data 279 | internalModel.payloadData.protocol_version = 2; 280 | internalModel.payloadData.tracking_id = Array.isArray(measurement_ids) ? measurement_ids : [measurement_ids]; 281 | internalModel.payloadData.client_id = config.client_id ? config.client_id : [randomInt(), timestampInSeconds()].join('.'); 282 | internalModel.payloadData._is_debug = config.debug ? 1 : undefined; 283 | internalModel.payloadData.non_personalized_ads = config.non_personalized_ads ? 1 : undefined; 284 | internalModel.payloadData.hit_count = 1; 285 | 286 | // Initialize Session Data 287 | internalModel.payloadData.session_id = config.session_id ? config.session_id : timestampInSeconds(); 288 | internalModel.payloadData.session_number = config.session_number ? config.session_number : 1; 289 | 290 | // Initialize User Data 291 | internalModel.payloadData.user_id = config.user_id ? trim(config.user_id, 256) : undefined; 292 | internalModel.payloadData.user_ip_address = config.user_ip_address ? config.user_ip_address : undefined; 293 | internalModel.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(version, "]"); 294 | 295 | // Initialize Tracker Data 296 | if (internalModel === 'node' && config.user_agent) { 297 | internalModel.user_agent = config.user_agent; 298 | } 299 | // Grab data only browser data 300 | if (internalModel.mode === 'browser') { 301 | var pageData = pageDetails(); 302 | if (pageData) { 303 | internalModel.payloadData = _extends(internalModel.payloadData, pageData); 304 | } 305 | } 306 | /** 307 | * Dispatching Queue 308 | * TO-DO 309 | */ 310 | var dispatchQueue = function dispatchQueue() { 311 | internalModel.queue = []; 312 | }; 313 | 314 | /** 315 | * Grab current ClientId 316 | * @returns string 317 | */ 318 | var getClientId = function getClientId() { 319 | return internalModel.payloadData.client_id; 320 | }; 321 | 322 | /** 323 | * Grab current Session ID 324 | * @returns string 325 | */ 326 | var getSessionId = function getSessionId() { 327 | return internalModel.payloadData.session_id; 328 | }; 329 | 330 | /** 331 | * Set an Sticky Event Parameter, it wil be attached to all events 332 | * @param {string} key 333 | * @param {string|number|Fn} value 334 | * @returns 335 | */ 336 | var setEventsParameter = function setEventsParameter(key, value) { 337 | if (isFunction(value)) { 338 | try { 339 | value = value(); 340 | } catch (e) {} 341 | } 342 | key = sanitizeValue(key, 40); 343 | value = sanitizeValue(value, 100); 344 | internalModel['persistentEventParameters'][key] = value; 345 | }; 346 | 347 | /** 348 | * setUserProperty 349 | * @param {*} key 350 | * @param {*} value 351 | * @returns 352 | */ 353 | var setUserProperty = function setUserProperty(key, value) { 354 | key = sanitizeValue(key, 24); 355 | value = sanitizeValue(value, 36); 356 | internalModel['userProperties'][key] = value; 357 | }; 358 | 359 | /** 360 | * Generate Payload 361 | * @param {object} customEventParameters 362 | */ 363 | var buildPayload = function buildPayload(eventName, customEventParameters) { 364 | var payload = {}; 365 | if (internalModel.payloadData.hit_count === 1) internalModel.payloadData.session_engaged = 1; 366 | Object.entries(internalModel.payloadData).forEach(function (pair) { 367 | var key = pair[0]; 368 | var value = pair[1]; 369 | if (ga4Schema[key]) { 370 | payload[ga4Schema[key]] = typeof value === 'boolean' ? +value : value; 371 | } 372 | }); 373 | // GA4 Will have different Limits based on "unknown" rules 374 | // const itemsLimit = isP ? 27 : 10 375 | var eventParameters = _extends(JSON.parse(JSON.stringify(internalModel.persistentEventParameters)), JSON.parse(JSON.stringify(customEventParameters))); 376 | eventParameters.event_name = eventName; 377 | Object.entries(eventParameters).forEach(function (pair) { 378 | var key = pair[0]; 379 | var value = pair[1]; 380 | if (key === 'items' && ecommerceEvents.indexOf(eventName) > -1 && Array.isArray(value)) { 381 | // only 200 items per event 382 | var items = value.slice(0, 200); 383 | var _loop = function _loop() { 384 | if (items[i]) { 385 | var item = { 386 | core: {}, 387 | custom: {} 388 | }; 389 | Object.entries(items[i]).forEach(function (pair) { 390 | if (ga4Schema[pair[0]]) { 391 | if (typeof pair[1] !== 'undefined') item.core[ga4Schema[pair[0]]] = pair[1]; 392 | } else item.custom[pair[0]] = pair[1]; 393 | }); 394 | var productString = Object.entries(item.core).map(function (v) { 395 | return v[0] + v[1]; 396 | }).join('~') + '~' + Object.entries(item.custom).map(function (v, i) { 397 | var customItemParamIndex = 10 > i ? '' + i : String.fromCharCode(65 + i - 10); 398 | return "k".concat(customItemParamIndex).concat(v[0], "~v").concat(customItemParamIndex).concat(v[1]); 399 | }).join('~'); 400 | payload["pr".concat(i + 1)] = productString; 401 | } 402 | }; 403 | for (var i = 0; i < items.length; i++) { 404 | _loop(); 405 | } 406 | } else { 407 | if (ga4Schema[key]) { 408 | payload[ga4Schema[key]] = typeof value === 'boolean' ? +value : value; 409 | } else { 410 | payload[(isNumber(value) ? 'epn.' : 'ep.') + key] = value; 411 | } 412 | } 413 | }); 414 | Object.entries(internalModel.userProperties).forEach(function (pair) { 415 | var key = pair[0]; 416 | var value = pair[1]; 417 | if (ga4Schema[key]) { 418 | payload[ga4Schema[key]] = typeof value === 'boolean' ? +value : value; 419 | } else { 420 | payload[(isNumber(value) ? 'upn.' : 'up.') + key] = value; 421 | } 422 | }); 423 | return payload; 424 | }; 425 | 426 | /** 427 | * setUserId 428 | * @param {string} value 429 | * @returns 430 | */ 431 | var setUserId = function setUserId(value) { 432 | internalModel.payloadData.user_id = sanitizeValue(value, 256); 433 | }; 434 | 435 | /** 436 | * Track Event 437 | * @param {string} eventName 438 | * @param {object} eventParameters 439 | * @param {boolean} forceDispatch 440 | */ 441 | var getHitIndex = function getHitIndex() { 442 | return internalModel.payloadData.hit_count; 443 | }; 444 | var trackEvent = function trackEvent(eventName) { 445 | var eventParameters = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 446 | var forceDispatch = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true; 447 | // We want to wait for the CH Promise to fullfill 448 | clientHints(internalModel === null || internalModel === void 0 ? void 0 : internalModel.mode).then(function (ch) { 449 | if (ch) { 450 | internalModel.payloadData = _extends(internalModel.payloadData, ch); 451 | } 452 | var payload = buildPayload(eventName, eventParameters); 453 | if (payload && forceDispatch) { 454 | for (var i = 0; i < payload.tid.length; i++) { 455 | var r = JSON.parse(JSON.stringify(payload)); 456 | r.tid = payload.tid[i]; 457 | sendRequest(internalModel.endpoint, r, internalModel.mode, { 458 | user_agent: internalModel === null || internalModel === void 0 ? void 0 : internalModel.user_agent 459 | }); 460 | } 461 | internalModel.payloadData.hit_count++; 462 | } else { 463 | var eventsCount = internalModel.queue.push(event); 464 | if (eventsCount >= internalModel.queueDispatchMaxEvents) { 465 | dispatchQueue(); 466 | } 467 | } 468 | }); 469 | }; 470 | return { 471 | version: internalModel.version, 472 | mode: internalModel.mode, 473 | getHitIndex: getHitIndex, 474 | getSessionId: getSessionId, 475 | getClientId: getClientId, 476 | setUserProperty: setUserProperty, 477 | setEventsParameter: setEventsParameter, 478 | setUserId: setUserId, 479 | trackEvent: trackEvent 480 | }; 481 | }; 482 | 483 | return ga4mp; 484 | 485 | })); 486 | -------------------------------------------------------------------------------- /dist/ga4mp.amd.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * @analytics-debugger/ga4mp 0.0.8 4 | * https://github.com/analytics-debugger/ga4mp 5 | * 6 | * Copyright (c) David Vallejo (https://www.thyngster.com). 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE file in the root directory of this source tree. 9 | * 10 | */ 11 | define((function(){"use strict";function e(){return e=Object.assign?Object.assign.bind():function(e){for(var n=1;n2&&void 0!==arguments[2]?arguments[2]:"browser",i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},a=new URLSearchParams(JSON.parse(JSON.stringify(n))).toString();if("browser"===t){var o;null===(o=navigator)||void 0===o||o.sendBeacon([e,a].join("?"))}else{var r=e.split("://")[0],s=require("".concat(r)),_={headers:{"User-Agent":i.user_agent},timeout:500},u=s.get([e,a].join("?"),_,(function(e){e.on("data",(function(e){})),e.on("end",(function(){}))})).on("error",(function(e){console.log("Error: "+e.message)}));u.on("timeout",(function(){u.destroy()}))}},_="0.0.8";return function(u){var d=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(!u)throw"Tracker initialization aborted: missing tracking ids";var c,l=e({version:_,debug:!1,mode:("undefined"!=typeof window&&void 0!==window.document?c="browser":"undefined"!=typeof process&&null!=process.versions&&null!=process.versions.node&&(c="node"),c||"browser"),measurement_ids:null,queueDispatchTime:5e3,queueDispatchMaxEvents:10,queue:[],eventParameters:{},persistentEventParameters:{},userProperties:{},user_agent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(_,"]"),user_ip_address:null,hooks:{beforeLoad:function(){},beforeRequestSend:function(){}},endpoint:"https://www.google-analytics.com/g/collect",payloadData:{}},d);if(l.payloadData.protocol_version=2,l.payloadData.tracking_id=Array.isArray(u)?u:[u],l.payloadData.client_id=d.client_id?d.client_id:[Math.floor(2147483648*Math.random()+0),i()].join("."),l.payloadData._is_debug=d.debug?1:void 0,l.payloadData.non_personalized_ads=d.non_personalized_ads?1:void 0,l.payloadData.hit_count=1,l.payloadData.session_id=d.session_id?d.session_id:i(),l.payloadData.session_number=d.session_number?d.session_number:1,l.payloadData.user_id=d.user_id?n(d.user_id,256):void 0,l.payloadData.user_ip_address=d.user_ip_address?d.user_ip_address:void 0,l.userAgent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(_,"]"),"node"===l&&d.user_agent&&(l.user_agent=d.user_agent),"browser"===l.mode){var p={page_location:document.location.href,page_referrer:document.referrer,page_title:document.title,language:(navigator&&(navigator.language||navigator.browserLanguage)||"").toLowerCase(),screen_resolution:(window.screen?window.screen.width:0)+"x"+(window.screen?window.screen.height:0)};p&&(l.payloadData=e(l.payloadData,p))}return{version:l.version,mode:l.mode,getHitIndex:function(){return l.payloadData.hit_count},getSessionId:function(){return l.payloadData.session_id},getClientId:function(){return l.payloadData.client_id},setUserProperty:function(e,n){e=a(e,24),n=a(n,36),l.userProperties[e]=n},setEventsParameter:function(e,n){if((t=n)&&("[object Function]"===Object.prototype.toString.call(t)||"function"==typeof t&&"[object RegExp]"!==Object.prototype.toString.call(t)))try{n=n()}catch(e){}var t;e=a(e,40),n=a(n,100),l.persistentEventParameters[e]=n},setUserId:function(e){l.payloadData.user_id=a(e,256)},trackEvent:function(n){var i,a,_,u=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},d=!(arguments.length>3&&void 0!==arguments[3])||arguments[3];(i=null==l?void 0:l.mode,"node"===i||"undefined"==typeof window||"undefined"!=typeof window&&!("navigator"in window)?new Promise((function(e){e(null)})):null!==(a=navigator)&&void 0!==a&&null!==(_=a.userAgentData)&&void 0!==_&&_.getHighEntropyValues?navigator.userAgentData.getHighEntropyValues(["platform","platformVersion","architecture","model","uaFullVersion","bitness","fullVersionList","wow64"]).then((function(e){var n,t,i,a,o,r;return{_user_agent_architecture:e.architecture,_user_agent_bitness:e.bitness,_user_agent_full_version_list:encodeURIComponent((Object.values(e.fullVersionList)||(null===(n=navigator)||void 0===n||null===(t=n.userAgentData)||void 0===t?void 0:t.brands)).map((function(e){return[e.brand,e.version].join(";")})).join("|")),_user_agent_mobile:e.mobile?1:0,_user_agent_model:e.model||(null===(i=navigator)||void 0===i||null===(a=i.userAgentData)||void 0===a?void 0:a.mobile),_user_agent_platform:e.platform||(null===(o=navigator)||void 0===o||null===(r=o.userAgentData)||void 0===r?void 0:r.platform),_user_agent_platform_version:e.platformVersion,_user_agent_wow64:e.wow64?1:0}})):new Promise((function(e){e(null)}))).then((function(i){i&&(l.payloadData=e(l.payloadData,i));var a=function(n,i){var a={};1===l.payloadData.hit_count&&(l.payloadData.session_engaged=1),Object.entries(l.payloadData).forEach((function(e){var n=e[0],t=e[1];o[n]&&(a[o[n]]="boolean"==typeof t?+t:t)}));var s=e(JSON.parse(JSON.stringify(l.persistentEventParameters)),JSON.parse(JSON.stringify(i)));return s.event_name=n,Object.entries(s).forEach((function(e){var i=e[0],s=e[1];if("items"===i&&r.indexOf(n)>-1&&Array.isArray(s))for(var _=s.slice(0,200),u=function(){if(_[d]){var e={core:{},custom:{}};Object.entries(_[d]).forEach((function(n){o[n[0]]?void 0!==n[1]&&(e.core[o[n[0]]]=n[1]):e.custom[n[0]]=n[1]}));var n=Object.entries(e.core).map((function(e){return e[0]+e[1]})).join("~")+"~"+Object.entries(e.custom).map((function(e,n){var t=10>n?""+n:String.fromCharCode(65+n-10);return"k".concat(t).concat(e[0],"~v").concat(t).concat(e[1])})).join("~");a["pr".concat(d+1)]=n}},d=0;d<_.length;d++)u();else o[i]?a[o[i]]="boolean"==typeof s?+s:s:a[(t(s)?"epn.":"ep.")+i]=s})),Object.entries(l.userProperties).forEach((function(e){var n=e[0],i=e[1];o[n]?a[o[n]]="boolean"==typeof i?+i:i:a[(t(i)?"upn.":"up.")+n]=i})),a}(n,u);if(a&&d){for(var _=0;_=l.queueDispatchMaxEvents&&(l.queue=[])}))}}}})); 12 | -------------------------------------------------------------------------------- /dist/ga4mp.esm.js: -------------------------------------------------------------------------------- 1 | const trim = (str, chars) => { 2 | if (typeof str === 'string') { 3 | return str.substring(0, chars) 4 | } else { 5 | return str 6 | } 7 | }; 8 | const isFunction = (val) => { 9 | if (!val) return false 10 | return ( 11 | Object.prototype.toString.call(val) === '[object Function]' || 12 | (typeof val === 'function' && 13 | Object.prototype.toString.call(val) !== '[object RegExp]') 14 | ) 15 | }; 16 | const isNumber = (val) => 'number' === typeof val && !isNaN(val); 17 | const isString = (val) => val != null && typeof val === 'string'; 18 | const randomInt = () => 19 | Math.floor(Math.random() * (2147483647 - 0 + 1) + 0); 20 | const timestampInSeconds = () => Math.floor((new Date() * 1) / 1000); 21 | const getEnvironment = () => { 22 | let env; 23 | if (typeof window !== 'undefined' && typeof window.document !== 'undefined') 24 | env = 'browser'; 25 | else if ( 26 | typeof process !== 'undefined' && 27 | process.versions != null && 28 | process.versions.node != null 29 | ) 30 | env = 'node'; 31 | return env 32 | }; 33 | 34 | /** 35 | * Function to sanitize values based on GA4 Model Limits 36 | * @param {string} val 37 | * @param {integer} maxLength 38 | * @returns 39 | */ 40 | 41 | const sanitizeValue = (val, maxLength) => { 42 | // Trim a key-value pair value based on GA4 limits 43 | /*eslint-disable */ 44 | try { 45 | val = val.toString(); 46 | } catch (e) {} 47 | /*eslint-enable */ 48 | if (!isString(val) || !maxLength || !isNumber(maxLength)) return val 49 | return trim(val, maxLength) 50 | }; 51 | 52 | const ga4Schema = { 53 | _em: 'em', 54 | event_name: 'en', 55 | protocol_version: 'v', 56 | _page_id: '_p', 57 | _is_debug: '_dbg', 58 | tracking_id: 'tid', 59 | hit_count: '_s', 60 | user_id: 'uid', 61 | client_id: 'cid', 62 | page_location: 'dl', 63 | language: 'ul', 64 | firebase_id: '_fid', 65 | traffic_type: 'tt', 66 | ignore_referrer: 'ir', 67 | screen_resolution: 'sr', 68 | global_developer_id_string: 'gdid', 69 | redact_device_info: '_rdi', 70 | geo_granularity: '_geo', 71 | _is_passthrough_cid: 'gtm_up', 72 | _is_linker_valid: '_glv', 73 | _user_agent_architecture: 'uaa', 74 | _user_agent_bitness: 'uab', 75 | _user_agent_full_version_list: 'uafvl', 76 | _user_agent_mobile: 'uamb', 77 | _user_agent_model: 'uam', 78 | _user_agent_platform: 'uap', 79 | _user_agent_platform_version: 'uapv', 80 | _user_agent_wait: 'uaW', 81 | _user_agent_wow64: 'uaw', 82 | error_code: 'ec', 83 | session_id: 'sid', 84 | session_number: 'sct', 85 | session_engaged: 'seg', 86 | page_referrer: 'dr', 87 | page_title: 'dt', 88 | currency: 'cu', 89 | campaign_content: 'cc', 90 | campaign_id: 'ci', 91 | campaign_medium: 'cm', 92 | campaign_name: 'cn', 93 | campaign_source: 'cs', 94 | campaign_term: 'ck', 95 | engagement_time_msec: '_et', 96 | event_developer_id_string: 'edid', 97 | is_first_visit: '_fv', 98 | is_new_to_site: '_nsi', 99 | is_session_start: '_ss', 100 | is_conversion: '_c', 101 | euid_mode_enabled: 'ecid', 102 | non_personalized_ads: '_npa', 103 | create_google_join: 'gaz', 104 | is_consent_update: 'gsu', 105 | user_ip_address: '_uip', 106 | google_consent_state: 'gcs', 107 | google_consent_update: 'gcu', 108 | us_privacy_string: 'us_privacy', 109 | document_location: 'dl', 110 | document_path: 'dp', 111 | document_title: 'dt', 112 | document_referrer: 'dr', 113 | user_language: 'ul', 114 | document_hostname: 'dh', 115 | item_id: 'id', 116 | item_name: 'nm', 117 | item_brand: 'br', 118 | item_category: 'ca', 119 | item_category2: 'c2', 120 | item_category3: 'c3', 121 | item_category4: 'c4', 122 | item_category5: 'c5', 123 | item_variant: 'va', 124 | price: 'pr', 125 | quantity: 'qt', 126 | coupon: 'cp', 127 | item_list_name: 'ln', 128 | index: 'lp', 129 | item_list_id: 'li', 130 | discount: 'ds', 131 | affiliation: 'af', 132 | promotion_id: 'pi', 133 | promotion_name: 'pn', 134 | creative_name: 'cn', 135 | creative_slot: 'cs', 136 | location_id: 'lo', 137 | // legacy ecommerce 138 | id: 'id', 139 | name: 'nm', 140 | brand: 'br', 141 | variant: 'va', 142 | list_name: 'ln', 143 | list_position: 'lp', 144 | list: 'ln', 145 | position: 'lp', 146 | creative: 'cn', 147 | }; 148 | 149 | const ecommerceEvents = [ 150 | 'add_payment_info', 151 | 'add_shipping_info', 152 | 'add_to_cart', 153 | 'remove_from_cart', 154 | 'view_cart', 155 | 'begin_checkout', 156 | 'select_item', 157 | 'view_item_list', 158 | 'select_promotion', 159 | 'view_promotion', 160 | 'purchase', 161 | 'refund', 162 | 'view_item', 163 | 'add_to_wishlist', 164 | ]; 165 | 166 | const sendRequest = (endpoint, payload, mode = 'browser', opts = {}) => { 167 | const qs = new URLSearchParams( 168 | JSON.parse(JSON.stringify(payload)) 169 | ).toString(); 170 | if (mode === 'browser') { 171 | navigator?.sendBeacon([endpoint, qs].join('?')); 172 | } else { 173 | const scheme = endpoint.split('://')[0]; 174 | const req = require(`${scheme}`); 175 | const options = { 176 | headers: { 177 | 'User-Agent': opts.user_agent 178 | }, 179 | timeout: 500, 180 | }; 181 | const request = req 182 | .get([endpoint, qs].join('?'), options, (resp) => { 183 | resp.on('data', (chunk) => { 184 | }); 185 | resp.on('end', () => { 186 | // TO-DO Handle Server Side Responses 187 | }); 188 | }) 189 | .on('error', (err) => { 190 | console.log('Error: ' + err.message); 191 | }); 192 | request.on('timeout', () => { 193 | request.destroy(); 194 | }); 195 | } 196 | }; 197 | 198 | const clientHints = (mode) => { 199 | if (mode === 'node' || typeof(window) === 'undefined' || typeof(window) !== 'undefined' && !('navigator' in window)) { 200 | return new Promise((resolve) => { 201 | resolve(null); 202 | }) 203 | } 204 | if (!navigator?.userAgentData?.getHighEntropyValues) 205 | return new Promise((resolve) => { 206 | resolve(null); 207 | }) 208 | return navigator.userAgentData 209 | .getHighEntropyValues([ 210 | 'platform', 211 | 'platformVersion', 212 | 'architecture', 213 | 'model', 214 | 'uaFullVersion', 215 | 'bitness', 216 | 'fullVersionList', 217 | 'wow64', 218 | ]) 219 | .then((d) => { 220 | return { 221 | _user_agent_architecture: d.architecture, 222 | _user_agent_bitness: d.bitness, 223 | _user_agent_full_version_list: encodeURIComponent( 224 | (Object.values(d.fullVersionList) || navigator?.userAgentData?.brands) 225 | .map((h) => { 226 | return [h.brand, h.version].join(';') 227 | }) 228 | .join('|') 229 | ), 230 | _user_agent_mobile: d.mobile ? 1 : 0, 231 | _user_agent_model: d.model || navigator?.userAgentData?.mobile, 232 | _user_agent_platform: d.platform || navigator?.userAgentData?.platform, 233 | _user_agent_platform_version: d.platformVersion, 234 | _user_agent_wow64: d.wow64 ? 1 : 0, 235 | } 236 | }) 237 | }; 238 | 239 | /** 240 | * Populate Page Related Details 241 | */ 242 | const pageDetails = () => { 243 | return { 244 | page_location: document.location.href, 245 | page_referrer: document.referrer, 246 | page_title: document.title, 247 | language: ( 248 | (navigator && (navigator.language || navigator.browserLanguage)) || 249 | '' 250 | ).toLowerCase(), 251 | screen_resolution: 252 | (window.screen ? window.screen.width : 0) + 253 | 'x' + 254 | (window.screen ? window.screen.height : 0), 255 | } 256 | }; 257 | 258 | const version = '0.0.8'; 259 | 260 | /** 261 | * Main Class Function 262 | * @param {array|string} measurement_ids 263 | * @param {object} config 264 | * @returns 265 | */ 266 | 267 | const ga4mp = function (measurement_ids, config = {}) { 268 | if (!measurement_ids) 269 | throw 'Tracker initialization aborted: missing tracking ids' 270 | const internalModel = Object.assign( 271 | { 272 | version, 273 | debug: false, 274 | mode: getEnvironment() || 'browser', 275 | measurement_ids: null, 276 | queueDispatchTime: 5000, 277 | queueDispatchMaxEvents: 10, 278 | queue: [], 279 | eventParameters: {}, 280 | persistentEventParameters: {}, 281 | userProperties: {}, 282 | user_agent: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/${version}]`, 283 | user_ip_address: null, 284 | hooks: { 285 | beforeLoad: () => {}, 286 | beforeRequestSend: () => {}, 287 | }, 288 | endpoint: 'https://www.google-analytics.com/g/collect', 289 | payloadData: {}, 290 | }, 291 | config 292 | ); 293 | 294 | // Initialize Tracker Data 295 | internalModel.payloadData.protocol_version = 2; 296 | internalModel.payloadData.tracking_id = Array.isArray(measurement_ids) 297 | ? measurement_ids 298 | : [measurement_ids]; 299 | internalModel.payloadData.client_id = config.client_id 300 | ? config.client_id 301 | : [randomInt(), timestampInSeconds()].join('.'); 302 | internalModel.payloadData._is_debug = config.debug ? 1 : undefined; 303 | internalModel.payloadData.non_personalized_ads = config.non_personalized_ads 304 | ? 1 305 | : undefined; 306 | internalModel.payloadData.hit_count = 1; 307 | 308 | // Initialize Session Data 309 | internalModel.payloadData.session_id = config.session_id 310 | ? config.session_id 311 | : timestampInSeconds(); 312 | internalModel.payloadData.session_number = config.session_number 313 | ? config.session_number 314 | : 1; 315 | 316 | // Initialize User Data 317 | internalModel.payloadData.user_id = config.user_id 318 | ? trim(config.user_id, 256) 319 | : undefined; 320 | internalModel.payloadData.user_ip_address = config.user_ip_address 321 | ? config.user_ip_address 322 | : undefined; 323 | internalModel.userAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/${version}]`; 324 | 325 | // Initialize Tracker Data 326 | if (internalModel === 'node' && config.user_agent) { 327 | internalModel.user_agent = config.user_agent; 328 | } 329 | // Grab data only browser data 330 | if (internalModel.mode === 'browser') { 331 | const pageData = pageDetails(); 332 | if (pageData) { 333 | internalModel.payloadData = Object.assign( 334 | internalModel.payloadData, 335 | pageData 336 | ); 337 | } 338 | } 339 | /** 340 | * Dispatching Queue 341 | * TO-DO 342 | */ 343 | const dispatchQueue = () => { 344 | internalModel.queue = []; 345 | }; 346 | 347 | /** 348 | * Grab current ClientId 349 | * @returns string 350 | */ 351 | const getClientId = () => { 352 | return internalModel.payloadData.client_id 353 | }; 354 | 355 | /** 356 | * Grab current Session ID 357 | * @returns string 358 | */ 359 | const getSessionId = () => { 360 | return internalModel.payloadData.session_id 361 | }; 362 | 363 | /** 364 | * Set an Sticky Event Parameter, it wil be attached to all events 365 | * @param {string} key 366 | * @param {string|number|Fn} value 367 | * @returns 368 | */ 369 | const setEventsParameter = (key, value) => { 370 | if (isFunction(value)) { 371 | try { 372 | value = value(); 373 | } catch (e) {} 374 | } 375 | key = sanitizeValue(key, 40); 376 | value = sanitizeValue(value, 100); 377 | internalModel['persistentEventParameters'][key] = value; 378 | }; 379 | 380 | /** 381 | * setUserProperty 382 | * @param {*} key 383 | * @param {*} value 384 | * @returns 385 | */ 386 | const setUserProperty = (key, value) => { 387 | key = sanitizeValue(key, 24); 388 | value = sanitizeValue(value, 36); 389 | internalModel['userProperties'][key] = value; 390 | }; 391 | 392 | /** 393 | * Generate Payload 394 | * @param {object} customEventParameters 395 | */ 396 | const buildPayload = (eventName, customEventParameters) => { 397 | const payload = {}; 398 | if (internalModel.payloadData.hit_count === 1) 399 | internalModel.payloadData.session_engaged = 1; 400 | 401 | Object.entries(internalModel.payloadData).forEach((pair) => { 402 | const key = pair[0]; 403 | const value = pair[1]; 404 | if (ga4Schema[key]) { 405 | payload[ga4Schema[key]] = 406 | typeof value === 'boolean' ? +value : value; 407 | } 408 | }); 409 | // GA4 Will have different Limits based on "unknown" rules 410 | // const itemsLimit = isP ? 27 : 10 411 | const eventParameters = Object.assign( 412 | JSON.parse(JSON.stringify(internalModel.persistentEventParameters)), 413 | JSON.parse(JSON.stringify(customEventParameters)) 414 | ); 415 | eventParameters.event_name = eventName; 416 | Object.entries(eventParameters).forEach((pair) => { 417 | const key = pair[0]; 418 | const value = pair[1]; 419 | if ( 420 | key === 'items' && 421 | ecommerceEvents.indexOf(eventName) > -1 && 422 | Array.isArray(value) 423 | ) { 424 | // only 200 items per event 425 | let items = value.slice(0, 200); 426 | for (let i = 0; i < items.length; i++) { 427 | if (items[i]) { 428 | const item = { 429 | core: {}, 430 | custom: {}, 431 | }; 432 | Object.entries(items[i]).forEach((pair) => { 433 | if (ga4Schema[pair[0]]) { 434 | if (typeof pair[1] !== 'undefined') 435 | item.core[ga4Schema[pair[0]]] = pair[1]; 436 | } else item.custom[pair[0]] = pair[1]; 437 | }); 438 | let productString = 439 | Object.entries(item.core) 440 | .map((v) => { 441 | return v[0] + v[1] 442 | }) 443 | .join('~') + 444 | '~' + 445 | Object.entries(item.custom) 446 | .map((v, i) => { 447 | var customItemParamIndex = 448 | 10 > i 449 | ? '' + i 450 | : String.fromCharCode(65 + i - 10); 451 | return `k${customItemParamIndex}${v[0]}~v${customItemParamIndex}${v[1]}` 452 | }) 453 | .join('~'); 454 | payload[`pr${i + 1}`] = productString; 455 | } 456 | } 457 | } else { 458 | if (ga4Schema[key]) { 459 | payload[ga4Schema[key]] = 460 | typeof value === 'boolean' ? +value : value; 461 | } else { 462 | payload[(isNumber(value) ? 'epn.' : 'ep.') + key] = value; 463 | } 464 | } 465 | }); 466 | Object.entries(internalModel.userProperties).forEach((pair) => { 467 | const key = pair[0]; 468 | const value = pair[1]; 469 | if (ga4Schema[key]) { 470 | payload[ga4Schema[key]] = 471 | typeof value === 'boolean' ? +value : value; 472 | } else { 473 | payload[(isNumber(value) ? 'upn.' : 'up.') + key] = value; 474 | } 475 | }); 476 | return payload 477 | }; 478 | 479 | /** 480 | * setUserId 481 | * @param {string} value 482 | * @returns 483 | */ 484 | const setUserId = (value) => { 485 | internalModel.payloadData.user_id = sanitizeValue(value, 256); 486 | }; 487 | 488 | /** 489 | * Track Event 490 | * @param {string} eventName 491 | * @param {object} eventParameters 492 | * @param {boolean} forceDispatch 493 | */ 494 | const getHitIndex = () => { 495 | return internalModel.payloadData.hit_count 496 | }; 497 | const trackEvent = ( 498 | eventName, 499 | eventParameters = {}, 500 | sessionControl = {}, 501 | forceDispatch = true 502 | ) => { 503 | // We want to wait for the CH Promise to fullfill 504 | clientHints(internalModel?.mode).then((ch) => { 505 | if (ch) { 506 | internalModel.payloadData = Object.assign( 507 | internalModel.payloadData, 508 | ch 509 | ); 510 | } 511 | const payload = buildPayload(eventName, eventParameters); 512 | if (payload && forceDispatch) { 513 | for (let i = 0; i < payload.tid.length; i++) { 514 | let r = JSON.parse(JSON.stringify(payload)); 515 | r.tid = payload.tid[i]; 516 | sendRequest(internalModel.endpoint, r, internalModel.mode, { 517 | user_agent: internalModel?.user_agent, 518 | }); 519 | } 520 | internalModel.payloadData.hit_count++; 521 | } else { 522 | const eventsCount = internalModel.queue.push(event); 523 | if (eventsCount >= internalModel.queueDispatchMaxEvents) { 524 | dispatchQueue(); 525 | } 526 | } 527 | }); 528 | }; 529 | return { 530 | version: internalModel.version, 531 | mode: internalModel.mode, 532 | getHitIndex, 533 | getSessionId, 534 | getClientId, 535 | setUserProperty, 536 | setEventsParameter, 537 | setUserId, 538 | trackEvent, 539 | } 540 | }; 541 | 542 | export { ga4mp as default }; 543 | -------------------------------------------------------------------------------- /dist/ga4mp.esm.min.js: -------------------------------------------------------------------------------- 1 | const e=(e,t)=>"string"==typeof e?e.substring(0,t):e,t=e=>"number"==typeof e&&!isNaN(e),a=()=>Math.floor(1*new Date/1e3),n=()=>{let e;return"undefined"!=typeof window&&void 0!==window.document?e="browser":"undefined"!=typeof process&&null!=process.versions&&null!=process.versions.node&&(e="node"),e},i=(a,n)=>{try{a=a.toString()}catch(e){}return(e=>null!=e&&"string"==typeof e)(a)&&n&&t(n)?e(a,n):a},o={_em:"em",event_name:"en",protocol_version:"v",_page_id:"_p",_is_debug:"_dbg",tracking_id:"tid",hit_count:"_s",user_id:"uid",client_id:"cid",page_location:"dl",language:"ul",firebase_id:"_fid",traffic_type:"tt",ignore_referrer:"ir",screen_resolution:"sr",global_developer_id_string:"gdid",redact_device_info:"_rdi",geo_granularity:"_geo",_is_passthrough_cid:"gtm_up",_is_linker_valid:"_glv",_user_agent_architecture:"uaa",_user_agent_bitness:"uab",_user_agent_full_version_list:"uafvl",_user_agent_mobile:"uamb",_user_agent_model:"uam",_user_agent_platform:"uap",_user_agent_platform_version:"uapv",_user_agent_wait:"uaW",_user_agent_wow64:"uaw",error_code:"ec",session_id:"sid",session_number:"sct",session_engaged:"seg",page_referrer:"dr",page_title:"dt",currency:"cu",campaign_content:"cc",campaign_id:"ci",campaign_medium:"cm",campaign_name:"cn",campaign_source:"cs",campaign_term:"ck",engagement_time_msec:"_et",event_developer_id_string:"edid",is_first_visit:"_fv",is_new_to_site:"_nsi",is_session_start:"_ss",is_conversion:"_c",euid_mode_enabled:"ecid",non_personalized_ads:"_npa",create_google_join:"gaz",is_consent_update:"gsu",user_ip_address:"_uip",google_consent_state:"gcs",google_consent_update:"gcu",us_privacy_string:"us_privacy",document_location:"dl",document_path:"dp",document_title:"dt",document_referrer:"dr",user_language:"ul",document_hostname:"dh",item_id:"id",item_name:"nm",item_brand:"br",item_category:"ca",item_category2:"c2",item_category3:"c3",item_category4:"c4",item_category5:"c5",item_variant:"va",price:"pr",quantity:"qt",coupon:"cp",item_list_name:"ln",index:"lp",item_list_id:"li",discount:"ds",affiliation:"af",promotion_id:"pi",promotion_name:"pn",creative_name:"cn",creative_slot:"cs",location_id:"lo",id:"id",name:"nm",brand:"br",variant:"va",list_name:"ln",list_position:"lp",list:"ln",position:"lp",creative:"cn"},r=["add_payment_info","add_shipping_info","add_to_cart","remove_from_cart","view_cart","begin_checkout","select_item","view_item_list","select_promotion","view_promotion","purchase","refund","view_item","add_to_wishlist"],s=(e,t,a="browser",n={})=>{const i=new URLSearchParams(JSON.parse(JSON.stringify(t))).toString();if("browser"===a)navigator?.sendBeacon([e,i].join("?"));else{const t=e.split("://")[0],a=require(`${t}`),o={headers:{"User-Agent":n.user_agent},timeout:500},r=a.get([e,i].join("?"),o,(e=>{e.on("data",(e=>{})),e.on("end",(()=>{}))})).on("error",(e=>{console.log("Error: "+e.message)}));r.on("timeout",(()=>{r.destroy()}))}},_="0.0.8",d=function(d,c={}){if(!d)throw"Tracker initialization aborted: missing tracking ids";const l=Object.assign({version:_,debug:!1,mode:n()||"browser",measurement_ids:null,queueDispatchTime:5e3,queueDispatchMaxEvents:10,queue:[],eventParameters:{},persistentEventParameters:{},userProperties:{},user_agent:`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/${_}]`,user_ip_address:null,hooks:{beforeLoad:()=>{},beforeRequestSend:()=>{}},endpoint:"https://www.google-analytics.com/g/collect",payloadData:{}},c);if(l.payloadData.protocol_version=2,l.payloadData.tracking_id=Array.isArray(d)?d:[d],l.payloadData.client_id=c.client_id?c.client_id:[Math.floor(2147483648*Math.random()+0),a()].join("."),l.payloadData._is_debug=c.debug?1:void 0,l.payloadData.non_personalized_ads=c.non_personalized_ads?1:void 0,l.payloadData.hit_count=1,l.payloadData.session_id=c.session_id?c.session_id:a(),l.payloadData.session_number=c.session_number?c.session_number:1,l.payloadData.user_id=c.user_id?e(c.user_id,256):void 0,l.payloadData.user_ip_address=c.user_ip_address?c.user_ip_address:void 0,l.userAgent=`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/${_}]`,"node"===l&&c.user_agent&&(l.user_agent=c.user_agent),"browser"===l.mode){const e={page_location:document.location.href,page_referrer:document.referrer,page_title:document.title,language:(navigator&&(navigator.language||navigator.browserLanguage)||"").toLowerCase(),screen_resolution:(window.screen?window.screen.width:0)+"x"+(window.screen?window.screen.height:0)};e&&(l.payloadData=Object.assign(l.payloadData,e))}return{version:l.version,mode:l.mode,getHitIndex:()=>l.payloadData.hit_count,getSessionId:()=>l.payloadData.session_id,getClientId:()=>l.payloadData.client_id,setUserProperty:(e,t)=>{e=i(e,24),t=i(t,36),l.userProperties[e]=t},setEventsParameter:(e,t)=>{if((a=t)&&("[object Function]"===Object.prototype.toString.call(a)||"function"==typeof a&&"[object RegExp]"!==Object.prototype.toString.call(a)))try{t=t()}catch(e){}var a;e=i(e,40),t=i(t,100),l.persistentEventParameters[e]=t},setUserId:e=>{l.payloadData.user_id=i(e,256)},trackEvent:(e,a={},n={},i=!0)=>{var _;(_=l?.mode,"node"===_||"undefined"==typeof window||"undefined"!=typeof window&&!("navigator"in window)?new Promise((e=>{e(null)})):navigator?.userAgentData?.getHighEntropyValues?navigator.userAgentData.getHighEntropyValues(["platform","platformVersion","architecture","model","uaFullVersion","bitness","fullVersionList","wow64"]).then((e=>({_user_agent_architecture:e.architecture,_user_agent_bitness:e.bitness,_user_agent_full_version_list:encodeURIComponent((Object.values(e.fullVersionList)||navigator?.userAgentData?.brands).map((e=>[e.brand,e.version].join(";"))).join("|")),_user_agent_mobile:e.mobile?1:0,_user_agent_model:e.model||navigator?.userAgentData?.mobile,_user_agent_platform:e.platform||navigator?.userAgentData?.platform,_user_agent_platform_version:e.platformVersion,_user_agent_wow64:e.wow64?1:0}))):new Promise((e=>{e(null)}))).then((n=>{n&&(l.payloadData=Object.assign(l.payloadData,n));const _=((e,a)=>{const n={};1===l.payloadData.hit_count&&(l.payloadData.session_engaged=1),Object.entries(l.payloadData).forEach((e=>{const t=e[0],a=e[1];o[t]&&(n[o[t]]="boolean"==typeof a?+a:a)}));const i=Object.assign(JSON.parse(JSON.stringify(l.persistentEventParameters)),JSON.parse(JSON.stringify(a)));return i.event_name=e,Object.entries(i).forEach((a=>{const i=a[0],s=a[1];if("items"===i&&r.indexOf(e)>-1&&Array.isArray(s)){let e=s.slice(0,200);for(let t=0;t{o[e[0]]?void 0!==e[1]&&(a.core[o[e[0]]]=e[1]):a.custom[e[0]]=e[1]}));let i=Object.entries(a.core).map((e=>e[0]+e[1])).join("~")+"~"+Object.entries(a.custom).map(((e,t)=>{var a=10>t?""+t:String.fromCharCode(65+t-10);return`k${a}${e[0]}~v${a}${e[1]}`})).join("~");n[`pr${t+1}`]=i}}else o[i]?n[o[i]]="boolean"==typeof s?+s:s:n[(t(s)?"epn.":"ep.")+i]=s})),Object.entries(l.userProperties).forEach((e=>{const a=e[0],i=e[1];o[a]?n[o[a]]="boolean"==typeof i?+i:i:n[(t(i)?"upn.":"up.")+a]=i})),n})(e,a);if(_&&i){for(let e=0;e<_.tid.length;e++){let t=JSON.parse(JSON.stringify(_));t.tid=_.tid[e],s(l.endpoint,t,l.mode,{user_agent:l?.user_agent})}l.payloadData.hit_count++}else l.queue.push(event)>=l.queueDispatchMaxEvents&&(l.queue=[])}))}}};export{d as default}; 2 | -------------------------------------------------------------------------------- /dist/ga4mp.iife.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * @analytics-debugger/ga4mp 0.0.8 4 | * https://github.com/analytics-debugger/ga4mp 5 | * 6 | * Copyright (c) David Vallejo (https://www.thyngster.com). 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE file in the root directory of this source tree. 9 | * 10 | */ 11 | 12 | var ga4mp = (function () { 13 | 'use strict'; 14 | 15 | function _extends() { 16 | _extends = Object.assign ? Object.assign.bind() : function (target) { 17 | for (var i = 1; i < arguments.length; i++) { 18 | var source = arguments[i]; 19 | for (var key in source) { 20 | if (Object.prototype.hasOwnProperty.call(source, key)) { 21 | target[key] = source[key]; 22 | } 23 | } 24 | } 25 | return target; 26 | }; 27 | return _extends.apply(this, arguments); 28 | } 29 | 30 | var trim = function trim(str, chars) { 31 | if (typeof str === 'string') { 32 | return str.substring(0, chars); 33 | } else { 34 | return str; 35 | } 36 | }; 37 | var isFunction = function isFunction(val) { 38 | if (!val) return false; 39 | return Object.prototype.toString.call(val) === '[object Function]' || typeof val === 'function' && Object.prototype.toString.call(val) !== '[object RegExp]'; 40 | }; 41 | var isNumber = function isNumber(val) { 42 | return 'number' === typeof val && !isNaN(val); 43 | }; 44 | var isString = function isString(val) { 45 | return val != null && typeof val === 'string'; 46 | }; 47 | var randomInt = function randomInt() { 48 | return Math.floor(Math.random() * (2147483647 - 0 + 1) + 0); 49 | }; 50 | var timestampInSeconds = function timestampInSeconds() { 51 | return Math.floor(new Date() * 1 / 1000); 52 | }; 53 | var getEnvironment = function getEnvironment() { 54 | var env; 55 | if (typeof window !== 'undefined' && typeof window.document !== 'undefined') env = 'browser';else if (typeof process !== 'undefined' && process.versions != null && process.versions.node != null) env = 'node'; 56 | return env; 57 | }; 58 | 59 | /** 60 | * Function to sanitize values based on GA4 Model Limits 61 | * @param {string} val 62 | * @param {integer} maxLength 63 | * @returns 64 | */ 65 | 66 | var sanitizeValue = function sanitizeValue(val, maxLength) { 67 | // Trim a key-value pair value based on GA4 limits 68 | /*eslint-disable */ 69 | try { 70 | val = val.toString(); 71 | } catch (e) {} 72 | /*eslint-enable */ 73 | if (!isString(val) || !maxLength || !isNumber(maxLength)) return val; 74 | return trim(val, maxLength); 75 | }; 76 | 77 | var ga4Schema = { 78 | _em: 'em', 79 | event_name: 'en', 80 | protocol_version: 'v', 81 | _page_id: '_p', 82 | _is_debug: '_dbg', 83 | tracking_id: 'tid', 84 | hit_count: '_s', 85 | user_id: 'uid', 86 | client_id: 'cid', 87 | page_location: 'dl', 88 | language: 'ul', 89 | firebase_id: '_fid', 90 | traffic_type: 'tt', 91 | ignore_referrer: 'ir', 92 | screen_resolution: 'sr', 93 | global_developer_id_string: 'gdid', 94 | redact_device_info: '_rdi', 95 | geo_granularity: '_geo', 96 | _is_passthrough_cid: 'gtm_up', 97 | _is_linker_valid: '_glv', 98 | _user_agent_architecture: 'uaa', 99 | _user_agent_bitness: 'uab', 100 | _user_agent_full_version_list: 'uafvl', 101 | _user_agent_mobile: 'uamb', 102 | _user_agent_model: 'uam', 103 | _user_agent_platform: 'uap', 104 | _user_agent_platform_version: 'uapv', 105 | _user_agent_wait: 'uaW', 106 | _user_agent_wow64: 'uaw', 107 | error_code: 'ec', 108 | session_id: 'sid', 109 | session_number: 'sct', 110 | session_engaged: 'seg', 111 | page_referrer: 'dr', 112 | page_title: 'dt', 113 | currency: 'cu', 114 | campaign_content: 'cc', 115 | campaign_id: 'ci', 116 | campaign_medium: 'cm', 117 | campaign_name: 'cn', 118 | campaign_source: 'cs', 119 | campaign_term: 'ck', 120 | engagement_time_msec: '_et', 121 | event_developer_id_string: 'edid', 122 | is_first_visit: '_fv', 123 | is_new_to_site: '_nsi', 124 | is_session_start: '_ss', 125 | is_conversion: '_c', 126 | euid_mode_enabled: 'ecid', 127 | non_personalized_ads: '_npa', 128 | create_google_join: 'gaz', 129 | is_consent_update: 'gsu', 130 | user_ip_address: '_uip', 131 | google_consent_state: 'gcs', 132 | google_consent_update: 'gcu', 133 | us_privacy_string: 'us_privacy', 134 | document_location: 'dl', 135 | document_path: 'dp', 136 | document_title: 'dt', 137 | document_referrer: 'dr', 138 | user_language: 'ul', 139 | document_hostname: 'dh', 140 | item_id: 'id', 141 | item_name: 'nm', 142 | item_brand: 'br', 143 | item_category: 'ca', 144 | item_category2: 'c2', 145 | item_category3: 'c3', 146 | item_category4: 'c4', 147 | item_category5: 'c5', 148 | item_variant: 'va', 149 | price: 'pr', 150 | quantity: 'qt', 151 | coupon: 'cp', 152 | item_list_name: 'ln', 153 | index: 'lp', 154 | item_list_id: 'li', 155 | discount: 'ds', 156 | affiliation: 'af', 157 | promotion_id: 'pi', 158 | promotion_name: 'pn', 159 | creative_name: 'cn', 160 | creative_slot: 'cs', 161 | location_id: 'lo', 162 | // legacy ecommerce 163 | id: 'id', 164 | name: 'nm', 165 | brand: 'br', 166 | variant: 'va', 167 | list_name: 'ln', 168 | list_position: 'lp', 169 | list: 'ln', 170 | position: 'lp', 171 | creative: 'cn' 172 | }; 173 | var ecommerceEvents = ['add_payment_info', 'add_shipping_info', 'add_to_cart', 'remove_from_cart', 'view_cart', 'begin_checkout', 'select_item', 'view_item_list', 'select_promotion', 'view_promotion', 'purchase', 'refund', 'view_item', 'add_to_wishlist']; 174 | 175 | var sendRequest = function sendRequest(endpoint, payload) { 176 | var mode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'browser'; 177 | var opts = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; 178 | var qs = new URLSearchParams(JSON.parse(JSON.stringify(payload))).toString(); 179 | if (mode === 'browser') { 180 | var _navigator; 181 | (_navigator = navigator) === null || _navigator === void 0 ? void 0 : _navigator.sendBeacon([endpoint, qs].join('?')); 182 | } else { 183 | var scheme = endpoint.split('://')[0]; 184 | var req = require("".concat(scheme)); 185 | var options = { 186 | headers: { 187 | 'User-Agent': opts.user_agent 188 | }, 189 | timeout: 500 190 | }; 191 | var request = req.get([endpoint, qs].join('?'), options, function (resp) { 192 | resp.on('data', function (chunk) { 193 | }); 194 | resp.on('end', function () { 195 | // TO-DO Handle Server Side Responses 196 | }); 197 | }).on('error', function (err) { 198 | console.log('Error: ' + err.message); 199 | }); 200 | request.on('timeout', function () { 201 | request.destroy(); 202 | }); 203 | } 204 | }; 205 | 206 | var clientHints = function clientHints(mode) { 207 | var _navigator, _navigator$userAgentD; 208 | if (mode === 'node' || typeof window === 'undefined' || typeof window !== 'undefined' && !('navigator' in window)) { 209 | return new Promise(function (resolve) { 210 | resolve(null); 211 | }); 212 | } 213 | if (!((_navigator = navigator) !== null && _navigator !== void 0 && (_navigator$userAgentD = _navigator.userAgentData) !== null && _navigator$userAgentD !== void 0 && _navigator$userAgentD.getHighEntropyValues)) return new Promise(function (resolve) { 214 | resolve(null); 215 | }); 216 | return navigator.userAgentData.getHighEntropyValues(['platform', 'platformVersion', 'architecture', 'model', 'uaFullVersion', 'bitness', 'fullVersionList', 'wow64']).then(function (d) { 217 | var _navigator2, _navigator2$userAgent, _navigator3, _navigator3$userAgent, _navigator4, _navigator4$userAgent; 218 | return { 219 | _user_agent_architecture: d.architecture, 220 | _user_agent_bitness: d.bitness, 221 | _user_agent_full_version_list: encodeURIComponent((Object.values(d.fullVersionList) || ((_navigator2 = navigator) === null || _navigator2 === void 0 ? void 0 : (_navigator2$userAgent = _navigator2.userAgentData) === null || _navigator2$userAgent === void 0 ? void 0 : _navigator2$userAgent.brands)).map(function (h) { 222 | return [h.brand, h.version].join(';'); 223 | }).join('|')), 224 | _user_agent_mobile: d.mobile ? 1 : 0, 225 | _user_agent_model: d.model || ((_navigator3 = navigator) === null || _navigator3 === void 0 ? void 0 : (_navigator3$userAgent = _navigator3.userAgentData) === null || _navigator3$userAgent === void 0 ? void 0 : _navigator3$userAgent.mobile), 226 | _user_agent_platform: d.platform || ((_navigator4 = navigator) === null || _navigator4 === void 0 ? void 0 : (_navigator4$userAgent = _navigator4.userAgentData) === null || _navigator4$userAgent === void 0 ? void 0 : _navigator4$userAgent.platform), 227 | _user_agent_platform_version: d.platformVersion, 228 | _user_agent_wow64: d.wow64 ? 1 : 0 229 | }; 230 | }); 231 | }; 232 | 233 | /** 234 | * Populate Page Related Details 235 | */ 236 | var pageDetails = function pageDetails() { 237 | return { 238 | page_location: document.location.href, 239 | page_referrer: document.referrer, 240 | page_title: document.title, 241 | language: (navigator && (navigator.language || navigator.browserLanguage) || '').toLowerCase(), 242 | screen_resolution: (window.screen ? window.screen.width : 0) + 'x' + (window.screen ? window.screen.height : 0) 243 | }; 244 | }; 245 | 246 | var version = '0.0.8'; 247 | 248 | /** 249 | * Main Class Function 250 | * @param {array|string} measurement_ids 251 | * @param {object} config 252 | * @returns 253 | */ 254 | 255 | var ga4mp = function ga4mp(measurement_ids) { 256 | var config = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 257 | if (!measurement_ids) throw 'Tracker initialization aborted: missing tracking ids'; 258 | var internalModel = _extends({ 259 | version: version, 260 | debug: false, 261 | mode: getEnvironment() || 'browser', 262 | measurement_ids: null, 263 | queueDispatchTime: 5000, 264 | queueDispatchMaxEvents: 10, 265 | queue: [], 266 | eventParameters: {}, 267 | persistentEventParameters: {}, 268 | userProperties: {}, 269 | user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(version, "]"), 270 | user_ip_address: null, 271 | hooks: { 272 | beforeLoad: function beforeLoad() {}, 273 | beforeRequestSend: function beforeRequestSend() {} 274 | }, 275 | endpoint: 'https://www.google-analytics.com/g/collect', 276 | payloadData: {} 277 | }, config); 278 | 279 | // Initialize Tracker Data 280 | internalModel.payloadData.protocol_version = 2; 281 | internalModel.payloadData.tracking_id = Array.isArray(measurement_ids) ? measurement_ids : [measurement_ids]; 282 | internalModel.payloadData.client_id = config.client_id ? config.client_id : [randomInt(), timestampInSeconds()].join('.'); 283 | internalModel.payloadData._is_debug = config.debug ? 1 : undefined; 284 | internalModel.payloadData.non_personalized_ads = config.non_personalized_ads ? 1 : undefined; 285 | internalModel.payloadData.hit_count = 1; 286 | 287 | // Initialize Session Data 288 | internalModel.payloadData.session_id = config.session_id ? config.session_id : timestampInSeconds(); 289 | internalModel.payloadData.session_number = config.session_number ? config.session_number : 1; 290 | 291 | // Initialize User Data 292 | internalModel.payloadData.user_id = config.user_id ? trim(config.user_id, 256) : undefined; 293 | internalModel.payloadData.user_ip_address = config.user_ip_address ? config.user_ip_address : undefined; 294 | internalModel.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(version, "]"); 295 | 296 | // Initialize Tracker Data 297 | if (internalModel === 'node' && config.user_agent) { 298 | internalModel.user_agent = config.user_agent; 299 | } 300 | // Grab data only browser data 301 | if (internalModel.mode === 'browser') { 302 | var pageData = pageDetails(); 303 | if (pageData) { 304 | internalModel.payloadData = _extends(internalModel.payloadData, pageData); 305 | } 306 | } 307 | /** 308 | * Dispatching Queue 309 | * TO-DO 310 | */ 311 | var dispatchQueue = function dispatchQueue() { 312 | internalModel.queue = []; 313 | }; 314 | 315 | /** 316 | * Grab current ClientId 317 | * @returns string 318 | */ 319 | var getClientId = function getClientId() { 320 | return internalModel.payloadData.client_id; 321 | }; 322 | 323 | /** 324 | * Grab current Session ID 325 | * @returns string 326 | */ 327 | var getSessionId = function getSessionId() { 328 | return internalModel.payloadData.session_id; 329 | }; 330 | 331 | /** 332 | * Set an Sticky Event Parameter, it wil be attached to all events 333 | * @param {string} key 334 | * @param {string|number|Fn} value 335 | * @returns 336 | */ 337 | var setEventsParameter = function setEventsParameter(key, value) { 338 | if (isFunction(value)) { 339 | try { 340 | value = value(); 341 | } catch (e) {} 342 | } 343 | key = sanitizeValue(key, 40); 344 | value = sanitizeValue(value, 100); 345 | internalModel['persistentEventParameters'][key] = value; 346 | }; 347 | 348 | /** 349 | * setUserProperty 350 | * @param {*} key 351 | * @param {*} value 352 | * @returns 353 | */ 354 | var setUserProperty = function setUserProperty(key, value) { 355 | key = sanitizeValue(key, 24); 356 | value = sanitizeValue(value, 36); 357 | internalModel['userProperties'][key] = value; 358 | }; 359 | 360 | /** 361 | * Generate Payload 362 | * @param {object} customEventParameters 363 | */ 364 | var buildPayload = function buildPayload(eventName, customEventParameters) { 365 | var payload = {}; 366 | if (internalModel.payloadData.hit_count === 1) internalModel.payloadData.session_engaged = 1; 367 | Object.entries(internalModel.payloadData).forEach(function (pair) { 368 | var key = pair[0]; 369 | var value = pair[1]; 370 | if (ga4Schema[key]) { 371 | payload[ga4Schema[key]] = typeof value === 'boolean' ? +value : value; 372 | } 373 | }); 374 | // GA4 Will have different Limits based on "unknown" rules 375 | // const itemsLimit = isP ? 27 : 10 376 | var eventParameters = _extends(JSON.parse(JSON.stringify(internalModel.persistentEventParameters)), JSON.parse(JSON.stringify(customEventParameters))); 377 | eventParameters.event_name = eventName; 378 | Object.entries(eventParameters).forEach(function (pair) { 379 | var key = pair[0]; 380 | var value = pair[1]; 381 | if (key === 'items' && ecommerceEvents.indexOf(eventName) > -1 && Array.isArray(value)) { 382 | // only 200 items per event 383 | var items = value.slice(0, 200); 384 | var _loop = function _loop() { 385 | if (items[i]) { 386 | var item = { 387 | core: {}, 388 | custom: {} 389 | }; 390 | Object.entries(items[i]).forEach(function (pair) { 391 | if (ga4Schema[pair[0]]) { 392 | if (typeof pair[1] !== 'undefined') item.core[ga4Schema[pair[0]]] = pair[1]; 393 | } else item.custom[pair[0]] = pair[1]; 394 | }); 395 | var productString = Object.entries(item.core).map(function (v) { 396 | return v[0] + v[1]; 397 | }).join('~') + '~' + Object.entries(item.custom).map(function (v, i) { 398 | var customItemParamIndex = 10 > i ? '' + i : String.fromCharCode(65 + i - 10); 399 | return "k".concat(customItemParamIndex).concat(v[0], "~v").concat(customItemParamIndex).concat(v[1]); 400 | }).join('~'); 401 | payload["pr".concat(i + 1)] = productString; 402 | } 403 | }; 404 | for (var i = 0; i < items.length; i++) { 405 | _loop(); 406 | } 407 | } else { 408 | if (ga4Schema[key]) { 409 | payload[ga4Schema[key]] = typeof value === 'boolean' ? +value : value; 410 | } else { 411 | payload[(isNumber(value) ? 'epn.' : 'ep.') + key] = value; 412 | } 413 | } 414 | }); 415 | Object.entries(internalModel.userProperties).forEach(function (pair) { 416 | var key = pair[0]; 417 | var value = pair[1]; 418 | if (ga4Schema[key]) { 419 | payload[ga4Schema[key]] = typeof value === 'boolean' ? +value : value; 420 | } else { 421 | payload[(isNumber(value) ? 'upn.' : 'up.') + key] = value; 422 | } 423 | }); 424 | return payload; 425 | }; 426 | 427 | /** 428 | * setUserId 429 | * @param {string} value 430 | * @returns 431 | */ 432 | var setUserId = function setUserId(value) { 433 | internalModel.payloadData.user_id = sanitizeValue(value, 256); 434 | }; 435 | 436 | /** 437 | * Track Event 438 | * @param {string} eventName 439 | * @param {object} eventParameters 440 | * @param {boolean} forceDispatch 441 | */ 442 | var getHitIndex = function getHitIndex() { 443 | return internalModel.payloadData.hit_count; 444 | }; 445 | var trackEvent = function trackEvent(eventName) { 446 | var eventParameters = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 447 | var forceDispatch = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true; 448 | // We want to wait for the CH Promise to fullfill 449 | clientHints(internalModel === null || internalModel === void 0 ? void 0 : internalModel.mode).then(function (ch) { 450 | if (ch) { 451 | internalModel.payloadData = _extends(internalModel.payloadData, ch); 452 | } 453 | var payload = buildPayload(eventName, eventParameters); 454 | if (payload && forceDispatch) { 455 | for (var i = 0; i < payload.tid.length; i++) { 456 | var r = JSON.parse(JSON.stringify(payload)); 457 | r.tid = payload.tid[i]; 458 | sendRequest(internalModel.endpoint, r, internalModel.mode, { 459 | user_agent: internalModel === null || internalModel === void 0 ? void 0 : internalModel.user_agent 460 | }); 461 | } 462 | internalModel.payloadData.hit_count++; 463 | } else { 464 | var eventsCount = internalModel.queue.push(event); 465 | if (eventsCount >= internalModel.queueDispatchMaxEvents) { 466 | dispatchQueue(); 467 | } 468 | } 469 | }); 470 | }; 471 | return { 472 | version: internalModel.version, 473 | mode: internalModel.mode, 474 | getHitIndex: getHitIndex, 475 | getSessionId: getSessionId, 476 | getClientId: getClientId, 477 | setUserProperty: setUserProperty, 478 | setEventsParameter: setEventsParameter, 479 | setUserId: setUserId, 480 | trackEvent: trackEvent 481 | }; 482 | }; 483 | 484 | return ga4mp; 485 | 486 | })(); 487 | -------------------------------------------------------------------------------- /dist/ga4mp.iife.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * @analytics-debugger/ga4mp 0.0.8 4 | * https://github.com/analytics-debugger/ga4mp 5 | * 6 | * Copyright (c) David Vallejo (https://www.thyngster.com). 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE file in the root directory of this source tree. 9 | * 10 | */ 11 | var ga4mp=function(){"use strict";function e(){return e=Object.assign?Object.assign.bind():function(e){for(var n=1;n2&&void 0!==arguments[2]?arguments[2]:"browser",i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},a=new URLSearchParams(JSON.parse(JSON.stringify(n))).toString();if("browser"===t){var o;null===(o=navigator)||void 0===o||o.sendBeacon([e,a].join("?"))}else{var r=e.split("://")[0],s=require("".concat(r)),_={headers:{"User-Agent":i.user_agent},timeout:500},u=s.get([e,a].join("?"),_,(function(e){e.on("data",(function(e){})),e.on("end",(function(){}))})).on("error",(function(e){console.log("Error: "+e.message)}));u.on("timeout",(function(){u.destroy()}))}},_="0.0.8";return function(u){var c=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(!u)throw"Tracker initialization aborted: missing tracking ids";var d,l=e({version:_,debug:!1,mode:("undefined"!=typeof window&&void 0!==window.document?d="browser":"undefined"!=typeof process&&null!=process.versions&&null!=process.versions.node&&(d="node"),d||"browser"),measurement_ids:null,queueDispatchTime:5e3,queueDispatchMaxEvents:10,queue:[],eventParameters:{},persistentEventParameters:{},userProperties:{},user_agent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(_,"]"),user_ip_address:null,hooks:{beforeLoad:function(){},beforeRequestSend:function(){}},endpoint:"https://www.google-analytics.com/g/collect",payloadData:{}},c);if(l.payloadData.protocol_version=2,l.payloadData.tracking_id=Array.isArray(u)?u:[u],l.payloadData.client_id=c.client_id?c.client_id:[Math.floor(2147483648*Math.random()+0),i()].join("."),l.payloadData._is_debug=c.debug?1:void 0,l.payloadData.non_personalized_ads=c.non_personalized_ads?1:void 0,l.payloadData.hit_count=1,l.payloadData.session_id=c.session_id?c.session_id:i(),l.payloadData.session_number=c.session_number?c.session_number:1,l.payloadData.user_id=c.user_id?n(c.user_id,256):void 0,l.payloadData.user_ip_address=c.user_ip_address?c.user_ip_address:void 0,l.userAgent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(_,"]"),"node"===l&&c.user_agent&&(l.user_agent=c.user_agent),"browser"===l.mode){var p={page_location:document.location.href,page_referrer:document.referrer,page_title:document.title,language:(navigator&&(navigator.language||navigator.browserLanguage)||"").toLowerCase(),screen_resolution:(window.screen?window.screen.width:0)+"x"+(window.screen?window.screen.height:0)};p&&(l.payloadData=e(l.payloadData,p))}return{version:l.version,mode:l.mode,getHitIndex:function(){return l.payloadData.hit_count},getSessionId:function(){return l.payloadData.session_id},getClientId:function(){return l.payloadData.client_id},setUserProperty:function(e,n){e=a(e,24),n=a(n,36),l.userProperties[e]=n},setEventsParameter:function(e,n){if((t=n)&&("[object Function]"===Object.prototype.toString.call(t)||"function"==typeof t&&"[object RegExp]"!==Object.prototype.toString.call(t)))try{n=n()}catch(e){}var t;e=a(e,40),n=a(n,100),l.persistentEventParameters[e]=n},setUserId:function(e){l.payloadData.user_id=a(e,256)},trackEvent:function(n){var i,a,_,u=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},c=!(arguments.length>3&&void 0!==arguments[3])||arguments[3];(i=null==l?void 0:l.mode,"node"===i||"undefined"==typeof window||"undefined"!=typeof window&&!("navigator"in window)?new Promise((function(e){e(null)})):null!==(a=navigator)&&void 0!==a&&null!==(_=a.userAgentData)&&void 0!==_&&_.getHighEntropyValues?navigator.userAgentData.getHighEntropyValues(["platform","platformVersion","architecture","model","uaFullVersion","bitness","fullVersionList","wow64"]).then((function(e){var n,t,i,a,o,r;return{_user_agent_architecture:e.architecture,_user_agent_bitness:e.bitness,_user_agent_full_version_list:encodeURIComponent((Object.values(e.fullVersionList)||(null===(n=navigator)||void 0===n||null===(t=n.userAgentData)||void 0===t?void 0:t.brands)).map((function(e){return[e.brand,e.version].join(";")})).join("|")),_user_agent_mobile:e.mobile?1:0,_user_agent_model:e.model||(null===(i=navigator)||void 0===i||null===(a=i.userAgentData)||void 0===a?void 0:a.mobile),_user_agent_platform:e.platform||(null===(o=navigator)||void 0===o||null===(r=o.userAgentData)||void 0===r?void 0:r.platform),_user_agent_platform_version:e.platformVersion,_user_agent_wow64:e.wow64?1:0}})):new Promise((function(e){e(null)}))).then((function(i){i&&(l.payloadData=e(l.payloadData,i));var a=function(n,i){var a={};1===l.payloadData.hit_count&&(l.payloadData.session_engaged=1),Object.entries(l.payloadData).forEach((function(e){var n=e[0],t=e[1];o[n]&&(a[o[n]]="boolean"==typeof t?+t:t)}));var s=e(JSON.parse(JSON.stringify(l.persistentEventParameters)),JSON.parse(JSON.stringify(i)));return s.event_name=n,Object.entries(s).forEach((function(e){var i=e[0],s=e[1];if("items"===i&&r.indexOf(n)>-1&&Array.isArray(s))for(var _=s.slice(0,200),u=function(){if(_[c]){var e={core:{},custom:{}};Object.entries(_[c]).forEach((function(n){o[n[0]]?void 0!==n[1]&&(e.core[o[n[0]]]=n[1]):e.custom[n[0]]=n[1]}));var n=Object.entries(e.core).map((function(e){return e[0]+e[1]})).join("~")+"~"+Object.entries(e.custom).map((function(e,n){var t=10>n?""+n:String.fromCharCode(65+n-10);return"k".concat(t).concat(e[0],"~v").concat(t).concat(e[1])})).join("~");a["pr".concat(c+1)]=n}},c=0;c<_.length;c++)u();else o[i]?a[o[i]]="boolean"==typeof s?+s:s:a[(t(s)?"epn.":"ep.")+i]=s})),Object.entries(l.userProperties).forEach((function(e){var n=e[0],i=e[1];o[n]?a[o[n]]="boolean"==typeof i?+i:i:a[(t(i)?"upn.":"up.")+n]=i})),a}(n,u);if(a&&c){for(var _=0;_=l.queueDispatchMaxEvents&&(l.queue=[])}))}}}}(); 12 | -------------------------------------------------------------------------------- /dist/ga4mp.umd.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * @analytics-debugger/ga4mp 0.0.8 4 | * https://github.com/analytics-debugger/ga4mp 5 | * 6 | * Copyright (c) David Vallejo (https://www.thyngster.com). 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE file in the root directory of this source tree. 9 | * 10 | */ 11 | 12 | (function (global, factory) { 13 | typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 14 | typeof define === 'function' && define.amd ? define(factory) : 15 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.ga4mp = factory()); 16 | })(this, (function () { 'use strict'; 17 | 18 | function _extends() { 19 | _extends = Object.assign ? Object.assign.bind() : function (target) { 20 | for (var i = 1; i < arguments.length; i++) { 21 | var source = arguments[i]; 22 | for (var key in source) { 23 | if (Object.prototype.hasOwnProperty.call(source, key)) { 24 | target[key] = source[key]; 25 | } 26 | } 27 | } 28 | return target; 29 | }; 30 | return _extends.apply(this, arguments); 31 | } 32 | 33 | var trim = function trim(str, chars) { 34 | if (typeof str === 'string') { 35 | return str.substring(0, chars); 36 | } else { 37 | return str; 38 | } 39 | }; 40 | var isFunction = function isFunction(val) { 41 | if (!val) return false; 42 | return Object.prototype.toString.call(val) === '[object Function]' || typeof val === 'function' && Object.prototype.toString.call(val) !== '[object RegExp]'; 43 | }; 44 | var isNumber = function isNumber(val) { 45 | return 'number' === typeof val && !isNaN(val); 46 | }; 47 | var isString = function isString(val) { 48 | return val != null && typeof val === 'string'; 49 | }; 50 | var randomInt = function randomInt() { 51 | return Math.floor(Math.random() * (2147483647 - 0 + 1) + 0); 52 | }; 53 | var timestampInSeconds = function timestampInSeconds() { 54 | return Math.floor(new Date() * 1 / 1000); 55 | }; 56 | var getEnvironment = function getEnvironment() { 57 | var env; 58 | if (typeof window !== 'undefined' && typeof window.document !== 'undefined') env = 'browser';else if (typeof process !== 'undefined' && process.versions != null && process.versions.node != null) env = 'node'; 59 | return env; 60 | }; 61 | 62 | /** 63 | * Function to sanitize values based on GA4 Model Limits 64 | * @param {string} val 65 | * @param {integer} maxLength 66 | * @returns 67 | */ 68 | 69 | var sanitizeValue = function sanitizeValue(val, maxLength) { 70 | // Trim a key-value pair value based on GA4 limits 71 | /*eslint-disable */ 72 | try { 73 | val = val.toString(); 74 | } catch (e) {} 75 | /*eslint-enable */ 76 | if (!isString(val) || !maxLength || !isNumber(maxLength)) return val; 77 | return trim(val, maxLength); 78 | }; 79 | 80 | var ga4Schema = { 81 | _em: 'em', 82 | event_name: 'en', 83 | protocol_version: 'v', 84 | _page_id: '_p', 85 | _is_debug: '_dbg', 86 | tracking_id: 'tid', 87 | hit_count: '_s', 88 | user_id: 'uid', 89 | client_id: 'cid', 90 | page_location: 'dl', 91 | language: 'ul', 92 | firebase_id: '_fid', 93 | traffic_type: 'tt', 94 | ignore_referrer: 'ir', 95 | screen_resolution: 'sr', 96 | global_developer_id_string: 'gdid', 97 | redact_device_info: '_rdi', 98 | geo_granularity: '_geo', 99 | _is_passthrough_cid: 'gtm_up', 100 | _is_linker_valid: '_glv', 101 | _user_agent_architecture: 'uaa', 102 | _user_agent_bitness: 'uab', 103 | _user_agent_full_version_list: 'uafvl', 104 | _user_agent_mobile: 'uamb', 105 | _user_agent_model: 'uam', 106 | _user_agent_platform: 'uap', 107 | _user_agent_platform_version: 'uapv', 108 | _user_agent_wait: 'uaW', 109 | _user_agent_wow64: 'uaw', 110 | error_code: 'ec', 111 | session_id: 'sid', 112 | session_number: 'sct', 113 | session_engaged: 'seg', 114 | page_referrer: 'dr', 115 | page_title: 'dt', 116 | currency: 'cu', 117 | campaign_content: 'cc', 118 | campaign_id: 'ci', 119 | campaign_medium: 'cm', 120 | campaign_name: 'cn', 121 | campaign_source: 'cs', 122 | campaign_term: 'ck', 123 | engagement_time_msec: '_et', 124 | event_developer_id_string: 'edid', 125 | is_first_visit: '_fv', 126 | is_new_to_site: '_nsi', 127 | is_session_start: '_ss', 128 | is_conversion: '_c', 129 | euid_mode_enabled: 'ecid', 130 | non_personalized_ads: '_npa', 131 | create_google_join: 'gaz', 132 | is_consent_update: 'gsu', 133 | user_ip_address: '_uip', 134 | google_consent_state: 'gcs', 135 | google_consent_update: 'gcu', 136 | us_privacy_string: 'us_privacy', 137 | document_location: 'dl', 138 | document_path: 'dp', 139 | document_title: 'dt', 140 | document_referrer: 'dr', 141 | user_language: 'ul', 142 | document_hostname: 'dh', 143 | item_id: 'id', 144 | item_name: 'nm', 145 | item_brand: 'br', 146 | item_category: 'ca', 147 | item_category2: 'c2', 148 | item_category3: 'c3', 149 | item_category4: 'c4', 150 | item_category5: 'c5', 151 | item_variant: 'va', 152 | price: 'pr', 153 | quantity: 'qt', 154 | coupon: 'cp', 155 | item_list_name: 'ln', 156 | index: 'lp', 157 | item_list_id: 'li', 158 | discount: 'ds', 159 | affiliation: 'af', 160 | promotion_id: 'pi', 161 | promotion_name: 'pn', 162 | creative_name: 'cn', 163 | creative_slot: 'cs', 164 | location_id: 'lo', 165 | // legacy ecommerce 166 | id: 'id', 167 | name: 'nm', 168 | brand: 'br', 169 | variant: 'va', 170 | list_name: 'ln', 171 | list_position: 'lp', 172 | list: 'ln', 173 | position: 'lp', 174 | creative: 'cn' 175 | }; 176 | var ecommerceEvents = ['add_payment_info', 'add_shipping_info', 'add_to_cart', 'remove_from_cart', 'view_cart', 'begin_checkout', 'select_item', 'view_item_list', 'select_promotion', 'view_promotion', 'purchase', 'refund', 'view_item', 'add_to_wishlist']; 177 | 178 | var sendRequest = function sendRequest(endpoint, payload) { 179 | var mode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'browser'; 180 | var opts = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; 181 | var qs = new URLSearchParams(JSON.parse(JSON.stringify(payload))).toString(); 182 | if (mode === 'browser') { 183 | var _navigator; 184 | (_navigator = navigator) === null || _navigator === void 0 ? void 0 : _navigator.sendBeacon([endpoint, qs].join('?')); 185 | } else { 186 | var scheme = endpoint.split('://')[0]; 187 | var req = require("".concat(scheme)); 188 | var options = { 189 | headers: { 190 | 'User-Agent': opts.user_agent 191 | }, 192 | timeout: 500 193 | }; 194 | var request = req.get([endpoint, qs].join('?'), options, function (resp) { 195 | resp.on('data', function (chunk) { 196 | }); 197 | resp.on('end', function () { 198 | // TO-DO Handle Server Side Responses 199 | }); 200 | }).on('error', function (err) { 201 | console.log('Error: ' + err.message); 202 | }); 203 | request.on('timeout', function () { 204 | request.destroy(); 205 | }); 206 | } 207 | }; 208 | 209 | var clientHints = function clientHints(mode) { 210 | var _navigator, _navigator$userAgentD; 211 | if (mode === 'node' || typeof window === 'undefined' || typeof window !== 'undefined' && !('navigator' in window)) { 212 | return new Promise(function (resolve) { 213 | resolve(null); 214 | }); 215 | } 216 | if (!((_navigator = navigator) !== null && _navigator !== void 0 && (_navigator$userAgentD = _navigator.userAgentData) !== null && _navigator$userAgentD !== void 0 && _navigator$userAgentD.getHighEntropyValues)) return new Promise(function (resolve) { 217 | resolve(null); 218 | }); 219 | return navigator.userAgentData.getHighEntropyValues(['platform', 'platformVersion', 'architecture', 'model', 'uaFullVersion', 'bitness', 'fullVersionList', 'wow64']).then(function (d) { 220 | var _navigator2, _navigator2$userAgent, _navigator3, _navigator3$userAgent, _navigator4, _navigator4$userAgent; 221 | return { 222 | _user_agent_architecture: d.architecture, 223 | _user_agent_bitness: d.bitness, 224 | _user_agent_full_version_list: encodeURIComponent((Object.values(d.fullVersionList) || ((_navigator2 = navigator) === null || _navigator2 === void 0 ? void 0 : (_navigator2$userAgent = _navigator2.userAgentData) === null || _navigator2$userAgent === void 0 ? void 0 : _navigator2$userAgent.brands)).map(function (h) { 225 | return [h.brand, h.version].join(';'); 226 | }).join('|')), 227 | _user_agent_mobile: d.mobile ? 1 : 0, 228 | _user_agent_model: d.model || ((_navigator3 = navigator) === null || _navigator3 === void 0 ? void 0 : (_navigator3$userAgent = _navigator3.userAgentData) === null || _navigator3$userAgent === void 0 ? void 0 : _navigator3$userAgent.mobile), 229 | _user_agent_platform: d.platform || ((_navigator4 = navigator) === null || _navigator4 === void 0 ? void 0 : (_navigator4$userAgent = _navigator4.userAgentData) === null || _navigator4$userAgent === void 0 ? void 0 : _navigator4$userAgent.platform), 230 | _user_agent_platform_version: d.platformVersion, 231 | _user_agent_wow64: d.wow64 ? 1 : 0 232 | }; 233 | }); 234 | }; 235 | 236 | /** 237 | * Populate Page Related Details 238 | */ 239 | var pageDetails = function pageDetails() { 240 | return { 241 | page_location: document.location.href, 242 | page_referrer: document.referrer, 243 | page_title: document.title, 244 | language: (navigator && (navigator.language || navigator.browserLanguage) || '').toLowerCase(), 245 | screen_resolution: (window.screen ? window.screen.width : 0) + 'x' + (window.screen ? window.screen.height : 0) 246 | }; 247 | }; 248 | 249 | var version = '0.0.8'; 250 | 251 | /** 252 | * Main Class Function 253 | * @param {array|string} measurement_ids 254 | * @param {object} config 255 | * @returns 256 | */ 257 | 258 | var ga4mp = function ga4mp(measurement_ids) { 259 | var config = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 260 | if (!measurement_ids) throw 'Tracker initialization aborted: missing tracking ids'; 261 | var internalModel = _extends({ 262 | version: version, 263 | debug: false, 264 | mode: getEnvironment() || 'browser', 265 | measurement_ids: null, 266 | queueDispatchTime: 5000, 267 | queueDispatchMaxEvents: 10, 268 | queue: [], 269 | eventParameters: {}, 270 | persistentEventParameters: {}, 271 | userProperties: {}, 272 | user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(version, "]"), 273 | user_ip_address: null, 274 | hooks: { 275 | beforeLoad: function beforeLoad() {}, 276 | beforeRequestSend: function beforeRequestSend() {} 277 | }, 278 | endpoint: 'https://www.google-analytics.com/g/collect', 279 | payloadData: {} 280 | }, config); 281 | 282 | // Initialize Tracker Data 283 | internalModel.payloadData.protocol_version = 2; 284 | internalModel.payloadData.tracking_id = Array.isArray(measurement_ids) ? measurement_ids : [measurement_ids]; 285 | internalModel.payloadData.client_id = config.client_id ? config.client_id : [randomInt(), timestampInSeconds()].join('.'); 286 | internalModel.payloadData._is_debug = config.debug ? 1 : undefined; 287 | internalModel.payloadData.non_personalized_ads = config.non_personalized_ads ? 1 : undefined; 288 | internalModel.payloadData.hit_count = 1; 289 | 290 | // Initialize Session Data 291 | internalModel.payloadData.session_id = config.session_id ? config.session_id : timestampInSeconds(); 292 | internalModel.payloadData.session_number = config.session_number ? config.session_number : 1; 293 | 294 | // Initialize User Data 295 | internalModel.payloadData.user_id = config.user_id ? trim(config.user_id, 256) : undefined; 296 | internalModel.payloadData.user_ip_address = config.user_ip_address ? config.user_ip_address : undefined; 297 | internalModel.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(version, "]"); 298 | 299 | // Initialize Tracker Data 300 | if (internalModel === 'node' && config.user_agent) { 301 | internalModel.user_agent = config.user_agent; 302 | } 303 | // Grab data only browser data 304 | if (internalModel.mode === 'browser') { 305 | var pageData = pageDetails(); 306 | if (pageData) { 307 | internalModel.payloadData = _extends(internalModel.payloadData, pageData); 308 | } 309 | } 310 | /** 311 | * Dispatching Queue 312 | * TO-DO 313 | */ 314 | var dispatchQueue = function dispatchQueue() { 315 | internalModel.queue = []; 316 | }; 317 | 318 | /** 319 | * Grab current ClientId 320 | * @returns string 321 | */ 322 | var getClientId = function getClientId() { 323 | return internalModel.payloadData.client_id; 324 | }; 325 | 326 | /** 327 | * Grab current Session ID 328 | * @returns string 329 | */ 330 | var getSessionId = function getSessionId() { 331 | return internalModel.payloadData.session_id; 332 | }; 333 | 334 | /** 335 | * Set an Sticky Event Parameter, it wil be attached to all events 336 | * @param {string} key 337 | * @param {string|number|Fn} value 338 | * @returns 339 | */ 340 | var setEventsParameter = function setEventsParameter(key, value) { 341 | if (isFunction(value)) { 342 | try { 343 | value = value(); 344 | } catch (e) {} 345 | } 346 | key = sanitizeValue(key, 40); 347 | value = sanitizeValue(value, 100); 348 | internalModel['persistentEventParameters'][key] = value; 349 | }; 350 | 351 | /** 352 | * setUserProperty 353 | * @param {*} key 354 | * @param {*} value 355 | * @returns 356 | */ 357 | var setUserProperty = function setUserProperty(key, value) { 358 | key = sanitizeValue(key, 24); 359 | value = sanitizeValue(value, 36); 360 | internalModel['userProperties'][key] = value; 361 | }; 362 | 363 | /** 364 | * Generate Payload 365 | * @param {object} customEventParameters 366 | */ 367 | var buildPayload = function buildPayload(eventName, customEventParameters) { 368 | var payload = {}; 369 | if (internalModel.payloadData.hit_count === 1) internalModel.payloadData.session_engaged = 1; 370 | Object.entries(internalModel.payloadData).forEach(function (pair) { 371 | var key = pair[0]; 372 | var value = pair[1]; 373 | if (ga4Schema[key]) { 374 | payload[ga4Schema[key]] = typeof value === 'boolean' ? +value : value; 375 | } 376 | }); 377 | // GA4 Will have different Limits based on "unknown" rules 378 | // const itemsLimit = isP ? 27 : 10 379 | var eventParameters = _extends(JSON.parse(JSON.stringify(internalModel.persistentEventParameters)), JSON.parse(JSON.stringify(customEventParameters))); 380 | eventParameters.event_name = eventName; 381 | Object.entries(eventParameters).forEach(function (pair) { 382 | var key = pair[0]; 383 | var value = pair[1]; 384 | if (key === 'items' && ecommerceEvents.indexOf(eventName) > -1 && Array.isArray(value)) { 385 | // only 200 items per event 386 | var items = value.slice(0, 200); 387 | var _loop = function _loop() { 388 | if (items[i]) { 389 | var item = { 390 | core: {}, 391 | custom: {} 392 | }; 393 | Object.entries(items[i]).forEach(function (pair) { 394 | if (ga4Schema[pair[0]]) { 395 | if (typeof pair[1] !== 'undefined') item.core[ga4Schema[pair[0]]] = pair[1]; 396 | } else item.custom[pair[0]] = pair[1]; 397 | }); 398 | var productString = Object.entries(item.core).map(function (v) { 399 | return v[0] + v[1]; 400 | }).join('~') + '~' + Object.entries(item.custom).map(function (v, i) { 401 | var customItemParamIndex = 10 > i ? '' + i : String.fromCharCode(65 + i - 10); 402 | return "k".concat(customItemParamIndex).concat(v[0], "~v").concat(customItemParamIndex).concat(v[1]); 403 | }).join('~'); 404 | payload["pr".concat(i + 1)] = productString; 405 | } 406 | }; 407 | for (var i = 0; i < items.length; i++) { 408 | _loop(); 409 | } 410 | } else { 411 | if (ga4Schema[key]) { 412 | payload[ga4Schema[key]] = typeof value === 'boolean' ? +value : value; 413 | } else { 414 | payload[(isNumber(value) ? 'epn.' : 'ep.') + key] = value; 415 | } 416 | } 417 | }); 418 | Object.entries(internalModel.userProperties).forEach(function (pair) { 419 | var key = pair[0]; 420 | var value = pair[1]; 421 | if (ga4Schema[key]) { 422 | payload[ga4Schema[key]] = typeof value === 'boolean' ? +value : value; 423 | } else { 424 | payload[(isNumber(value) ? 'upn.' : 'up.') + key] = value; 425 | } 426 | }); 427 | return payload; 428 | }; 429 | 430 | /** 431 | * setUserId 432 | * @param {string} value 433 | * @returns 434 | */ 435 | var setUserId = function setUserId(value) { 436 | internalModel.payloadData.user_id = sanitizeValue(value, 256); 437 | }; 438 | 439 | /** 440 | * Track Event 441 | * @param {string} eventName 442 | * @param {object} eventParameters 443 | * @param {boolean} forceDispatch 444 | */ 445 | var getHitIndex = function getHitIndex() { 446 | return internalModel.payloadData.hit_count; 447 | }; 448 | var trackEvent = function trackEvent(eventName) { 449 | var eventParameters = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 450 | var forceDispatch = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true; 451 | // We want to wait for the CH Promise to fullfill 452 | clientHints(internalModel === null || internalModel === void 0 ? void 0 : internalModel.mode).then(function (ch) { 453 | if (ch) { 454 | internalModel.payloadData = _extends(internalModel.payloadData, ch); 455 | } 456 | var payload = buildPayload(eventName, eventParameters); 457 | if (payload && forceDispatch) { 458 | for (var i = 0; i < payload.tid.length; i++) { 459 | var r = JSON.parse(JSON.stringify(payload)); 460 | r.tid = payload.tid[i]; 461 | sendRequest(internalModel.endpoint, r, internalModel.mode, { 462 | user_agent: internalModel === null || internalModel === void 0 ? void 0 : internalModel.user_agent 463 | }); 464 | } 465 | internalModel.payloadData.hit_count++; 466 | } else { 467 | var eventsCount = internalModel.queue.push(event); 468 | if (eventsCount >= internalModel.queueDispatchMaxEvents) { 469 | dispatchQueue(); 470 | } 471 | } 472 | }); 473 | }; 474 | return { 475 | version: internalModel.version, 476 | mode: internalModel.mode, 477 | getHitIndex: getHitIndex, 478 | getSessionId: getSessionId, 479 | getClientId: getClientId, 480 | setUserProperty: setUserProperty, 481 | setEventsParameter: setEventsParameter, 482 | setUserId: setUserId, 483 | trackEvent: trackEvent 484 | }; 485 | }; 486 | 487 | return ga4mp; 488 | 489 | })); 490 | -------------------------------------------------------------------------------- /dist/ga4mp.umd.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * @analytics-debugger/ga4mp 0.0.8 4 | * https://github.com/analytics-debugger/ga4mp 5 | * 6 | * Copyright (c) David Vallejo (https://www.thyngster.com). 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE file in the root directory of this source tree. 9 | * 10 | */ 11 | !function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e="undefined"!=typeof globalThis?globalThis:e||self).ga4mp=n()}(this,(function(){"use strict";function e(){return e=Object.assign?Object.assign.bind():function(e){for(var n=1;n2&&void 0!==arguments[2]?arguments[2]:"browser",i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},o=new URLSearchParams(JSON.parse(JSON.stringify(n))).toString();if("browser"===t){var a;null===(a=navigator)||void 0===a||a.sendBeacon([e,o].join("?"))}else{var r=e.split("://")[0],s=require("".concat(r)),_={headers:{"User-Agent":i.user_agent},timeout:500},u=s.get([e,o].join("?"),_,(function(e){e.on("data",(function(e){})),e.on("end",(function(){}))})).on("error",(function(e){console.log("Error: "+e.message)}));u.on("timeout",(function(){u.destroy()}))}},_="0.0.8";return function(u){var d=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(!u)throw"Tracker initialization aborted: missing tracking ids";var c,l=e({version:_,debug:!1,mode:("undefined"!=typeof window&&void 0!==window.document?c="browser":"undefined"!=typeof process&&null!=process.versions&&null!=process.versions.node&&(c="node"),c||"browser"),measurement_ids:null,queueDispatchTime:5e3,queueDispatchMaxEvents:10,queue:[],eventParameters:{},persistentEventParameters:{},userProperties:{},user_agent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(_,"]"),user_ip_address:null,hooks:{beforeLoad:function(){},beforeRequestSend:function(){}},endpoint:"https://www.google-analytics.com/g/collect",payloadData:{}},d);if(l.payloadData.protocol_version=2,l.payloadData.tracking_id=Array.isArray(u)?u:[u],l.payloadData.client_id=d.client_id?d.client_id:[Math.floor(2147483648*Math.random()+0),i()].join("."),l.payloadData._is_debug=d.debug?1:void 0,l.payloadData.non_personalized_ads=d.non_personalized_ads?1:void 0,l.payloadData.hit_count=1,l.payloadData.session_id=d.session_id?d.session_id:i(),l.payloadData.session_number=d.session_number?d.session_number:1,l.payloadData.user_id=d.user_id?n(d.user_id,256):void 0,l.payloadData.user_ip_address=d.user_ip_address?d.user_ip_address:void 0,l.userAgent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/".concat(_,"]"),"node"===l&&d.user_agent&&(l.user_agent=d.user_agent),"browser"===l.mode){var p={page_location:document.location.href,page_referrer:document.referrer,page_title:document.title,language:(navigator&&(navigator.language||navigator.browserLanguage)||"").toLowerCase(),screen_resolution:(window.screen?window.screen.width:0)+"x"+(window.screen?window.screen.height:0)};p&&(l.payloadData=e(l.payloadData,p))}return{version:l.version,mode:l.mode,getHitIndex:function(){return l.payloadData.hit_count},getSessionId:function(){return l.payloadData.session_id},getClientId:function(){return l.payloadData.client_id},setUserProperty:function(e,n){e=o(e,24),n=o(n,36),l.userProperties[e]=n},setEventsParameter:function(e,n){if((t=n)&&("[object Function]"===Object.prototype.toString.call(t)||"function"==typeof t&&"[object RegExp]"!==Object.prototype.toString.call(t)))try{n=n()}catch(e){}var t;e=o(e,40),n=o(n,100),l.persistentEventParameters[e]=n},setUserId:function(e){l.payloadData.user_id=o(e,256)},trackEvent:function(n){var i,o,_,u=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},d=!(arguments.length>3&&void 0!==arguments[3])||arguments[3];(i=null==l?void 0:l.mode,"node"===i||"undefined"==typeof window||"undefined"!=typeof window&&!("navigator"in window)?new Promise((function(e){e(null)})):null!==(o=navigator)&&void 0!==o&&null!==(_=o.userAgentData)&&void 0!==_&&_.getHighEntropyValues?navigator.userAgentData.getHighEntropyValues(["platform","platformVersion","architecture","model","uaFullVersion","bitness","fullVersionList","wow64"]).then((function(e){var n,t,i,o,a,r;return{_user_agent_architecture:e.architecture,_user_agent_bitness:e.bitness,_user_agent_full_version_list:encodeURIComponent((Object.values(e.fullVersionList)||(null===(n=navigator)||void 0===n||null===(t=n.userAgentData)||void 0===t?void 0:t.brands)).map((function(e){return[e.brand,e.version].join(";")})).join("|")),_user_agent_mobile:e.mobile?1:0,_user_agent_model:e.model||(null===(i=navigator)||void 0===i||null===(o=i.userAgentData)||void 0===o?void 0:o.mobile),_user_agent_platform:e.platform||(null===(a=navigator)||void 0===a||null===(r=a.userAgentData)||void 0===r?void 0:r.platform),_user_agent_platform_version:e.platformVersion,_user_agent_wow64:e.wow64?1:0}})):new Promise((function(e){e(null)}))).then((function(i){i&&(l.payloadData=e(l.payloadData,i));var o=function(n,i){var o={};1===l.payloadData.hit_count&&(l.payloadData.session_engaged=1),Object.entries(l.payloadData).forEach((function(e){var n=e[0],t=e[1];a[n]&&(o[a[n]]="boolean"==typeof t?+t:t)}));var s=e(JSON.parse(JSON.stringify(l.persistentEventParameters)),JSON.parse(JSON.stringify(i)));return s.event_name=n,Object.entries(s).forEach((function(e){var i=e[0],s=e[1];if("items"===i&&r.indexOf(n)>-1&&Array.isArray(s))for(var _=s.slice(0,200),u=function(){if(_[d]){var e={core:{},custom:{}};Object.entries(_[d]).forEach((function(n){a[n[0]]?void 0!==n[1]&&(e.core[a[n[0]]]=n[1]):e.custom[n[0]]=n[1]}));var n=Object.entries(e.core).map((function(e){return e[0]+e[1]})).join("~")+"~"+Object.entries(e.custom).map((function(e,n){var t=10>n?""+n:String.fromCharCode(65+n-10);return"k".concat(t).concat(e[0],"~v").concat(t).concat(e[1])})).join("~");o["pr".concat(d+1)]=n}},d=0;d<_.length;d++)u();else a[i]?o[a[i]]="boolean"==typeof s?+s:s:o[(t(s)?"epn.":"ep.")+i]=s})),Object.entries(l.userProperties).forEach((function(e){var n=e[0],i=e[1];a[n]?o[a[n]]="boolean"==typeof i?+i:i:o[(t(i)?"upn.":"up.")+n]=i})),o}(n,u);if(o&&d){for(var _=0;_=l.queueDispatchMaxEvents&&(l.queue=[])}))}}}})); 12 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytics-debugger/ga4mp/b2e66f0709b2be583561db4277a25469930b736f/docs/.nojekyll -------------------------------------------------------------------------------- /docs/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveServer.settings.port": 5501 3 | } -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | Welcome to GA4MP 2 | 3 | # Introduction 4 | 5 | 6 | This is an open-source implementation for the *client-side* protocol used by **Google Analytics 4**. When I mention "client-side" is because it must be differentiated with the official [GA4 Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/ga4) offered by Google. 7 | 8 | This library will use the public protocol definition, to make possible to build a ful server-side tracking which is not actually possible with the official Measurement Protocol. Since it's meant onlyu to augment the current data and not for performing a full tracking. 9 | 10 | Main differences with the official offerser server-side protocol are: 11 | 12 | - Trigger new sessions and visits starts 13 | - Track Sessions attribution 14 | - Override the User IP to populate the GEO Details 15 | - View the hits on the DebugView 16 | - Override ANY value you want 17 | - Full documentation offered for all the payload parameters , so this library can be ported to other languages. 18 | 19 | 20 | Other in-built features are: 21 | 22 | - Lightweight <3kb, have a full control about how your data is sent to Google. ( *privacy* ) 23 | - Data is sanitized, this means that event keys and paremeters are trimmed to the right length, same with user properties and user Ids. 24 | - We can use callback functions with over parameters values 25 | - Future inclusions of hooks. You'll be able to run some middleware to manage your sessions details using the localStorage, cookies or any other storage mechanism, of run a "**customTask**" to clean the hits before they are sent to Google Services 26 | - Parallel tracking, send hits to multiple Measurement ID's 27 | - Upcoming GTM Template upon this library is on some stable branch 28 | - Sending events to multiple Measurement IDs 29 |
  • Proper session handling: mark a session_start, first_visit, session_engaged values
  • 30 |
  • Event Parameters, User Properties Sanitization ( key, values length )
  • 31 |
  • Setting Attribution Parameters ( medium, source, campaign, )
  • 32 |
  • Pass the User Ip address so GEO data is populated ( for NODE.js Implementations )
  • 33 |
  • Setting the Engagment Time for the events
  • 34 |
  • Payload is built using friendly named keys that will automatically mapped into the payload ( check table below )
  • 35 |
  • Sending the hits to a custom endpoints
  • 36 |
  • Setting sticky values to events (no need to specify all the parameters for subsecuents events)
  • 37 |
  • Real Time debug issues reporting ( if any value is trimmed or skipped )
  • 38 |
  • If the environment is a browser, related dimensions will be auto-populated ( location, refererer, screen_size, language , etc)
  • -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # getHitIndex 2 | returns the current hit index 3 | 4 | /** 5 | * Grab current session Id 6 | * @returns number 7 | */ 8 | 9 | ```javascript 10 | tracker.getHitIndex() 11 | ``` 12 | # getSessionId 13 | returns the current sessonId set on the tracker 14 | 15 | /** 16 | * Grab current session Id 17 | * @returns string 18 | */ 19 | 20 | ```javascript 21 | tracker.getSessionId() 22 | ``` 23 | # getClientId 24 | returns the current clientId set on the tracker 25 | 26 | /** 27 | * Grab current ClientId 28 | * @returns string 29 | */ 30 | 31 | ```javascript 32 | tracker.getClientId() 33 | ``` 34 | # setUserProperty 35 | Set's the user property to be attached to all hits 36 | 37 | /** 38 | * setUserProperty 39 | * @param {*} key 40 | * @param {*} value 41 | * @returns 42 | */ 43 | 44 | ```javascript 45 | tracker.setUserProperty('lifetime_value_bucket', '200-300') 46 | ``` 47 | 48 | # setEventsParameter 49 | The event Parameter set will persist accross the subsecuent hits. 50 | 51 | /** 52 | * Set an Sticky Event Parameter, it wil be attached to all events 53 | * @param {string} key 54 | * @param {string|number|Fn} value 55 | * @returns 56 | */ 57 | 58 | ```javascript 59 | tracker.setEventsParameter('logged_in') 60 | ``` 61 | # setUserId 62 | 63 | Set's the user_id ```&uid``` for all the next hits. Value is trimmed to 256 Characters 64 | 65 | /** 66 | * setUserId 67 | * @param {string} value 68 | * @returns 69 | */ 70 | 71 | ```javascript 72 | tracker.setUserId('my_user_client_id') 73 | ``` -------------------------------------------------------------------------------- /docs/assets/search.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | /** 3 | * Converts a colon formatted string to a object with properties. 4 | * 5 | * This is process a provided string and look for any tokens in the format 6 | * of `:name[=value]` and then convert it to a object and return. 7 | * An example of this is ':include :type=code :fragment=demo' is taken and 8 | * then converted to: 9 | * 10 | * ``` 11 | * { 12 | * include: '', 13 | * type: 'code', 14 | * fragment: 'demo' 15 | * } 16 | * ``` 17 | * 18 | * @param {string} str The string to parse. 19 | * 20 | * @return {object} The original string and parsed object, { str, config }. 21 | */ 22 | function getAndRemoveConfig(str) { 23 | if ( str === void 0 ) str = ''; 24 | 25 | var config = {}; 26 | 27 | if (str) { 28 | str = str 29 | .replace(/^('|")/, '') 30 | .replace(/('|")$/, '') 31 | .replace(/(?:^|\s):([\w-]+:?)=?([\w-%]+)?/g, function (m, key, value) { 32 | if (key.indexOf(':') === -1) { 33 | config[key] = (value && value.replace(/"/g, '')) || true; 34 | return ''; 35 | } 36 | 37 | return m; 38 | }) 39 | .trim(); 40 | } 41 | 42 | return { str: str, config: config }; 43 | } 44 | 45 | function removeDocsifyIgnoreTag(str) { 46 | return str 47 | .replace(//, '') 48 | .replace(/{docsify-ignore}/, '') 49 | .replace(//, '') 50 | .replace(/{docsify-ignore-all}/, '') 51 | .trim(); 52 | } 53 | 54 | /* eslint-disable no-unused-vars */ 55 | 56 | var INDEXS = {}; 57 | 58 | var LOCAL_STORAGE = { 59 | EXPIRE_KEY: 'docsify.search.expires', 60 | INDEX_KEY: 'docsify.search.index', 61 | }; 62 | 63 | function resolveExpireKey(namespace) { 64 | return namespace 65 | ? ((LOCAL_STORAGE.EXPIRE_KEY) + "/" + namespace) 66 | : LOCAL_STORAGE.EXPIRE_KEY; 67 | } 68 | 69 | function resolveIndexKey(namespace) { 70 | return namespace 71 | ? ((LOCAL_STORAGE.INDEX_KEY) + "/" + namespace) 72 | : LOCAL_STORAGE.INDEX_KEY; 73 | } 74 | 75 | function escapeHtml(string) { 76 | var entityMap = { 77 | '&': '&', 78 | '<': '<', 79 | '>': '>', 80 | '"': '"', 81 | "'": ''', 82 | }; 83 | 84 | return String(string).replace(/[&<>"']/g, function (s) { return entityMap[s]; }); 85 | } 86 | 87 | function getAllPaths(router) { 88 | var paths = []; 89 | 90 | Docsify.dom 91 | .findAll('.sidebar-nav a:not(.section-link):not([data-nosearch])') 92 | .forEach(function (node) { 93 | var href = node.href; 94 | var originHref = node.getAttribute('href'); 95 | var path = router.parse(href).path; 96 | 97 | if ( 98 | path && 99 | paths.indexOf(path) === -1 && 100 | !Docsify.util.isAbsolutePath(originHref) 101 | ) { 102 | paths.push(path); 103 | } 104 | }); 105 | 106 | return paths; 107 | } 108 | 109 | function getTableData(token) { 110 | if (!token.text && token.type === 'table') { 111 | token.cells.unshift(token.header); 112 | token.text = token.cells 113 | .map(function (rows) { 114 | return rows.join(' | '); 115 | }) 116 | .join(' |\n '); 117 | } 118 | return token.text; 119 | } 120 | 121 | function getListData(token) { 122 | if (!token.text && token.type === 'list') { 123 | token.text = token.raw; 124 | } 125 | return token.text; 126 | } 127 | 128 | function saveData(maxAge, expireKey, indexKey) { 129 | localStorage.setItem(expireKey, Date.now() + maxAge); 130 | localStorage.setItem(indexKey, JSON.stringify(INDEXS)); 131 | } 132 | 133 | function genIndex(path, content, router, depth) { 134 | if ( content === void 0 ) content = ''; 135 | 136 | var tokens = window.marked.lexer(content); 137 | var slugify = window.Docsify.slugify; 138 | var index = {}; 139 | var slug; 140 | var title = ''; 141 | 142 | tokens.forEach(function (token, tokenIndex) { 143 | if (token.type === 'heading' && token.depth <= depth) { 144 | var ref = getAndRemoveConfig(token.text); 145 | var str = ref.str; 146 | var config = ref.config; 147 | 148 | var text = removeDocsifyIgnoreTag(token.text); 149 | 150 | if (config.id) { 151 | slug = router.toURL(path, { id: slugify(config.id) }); 152 | } else { 153 | slug = router.toURL(path, { id: slugify(escapeHtml(text)) }); 154 | } 155 | 156 | if (str) { 157 | title = removeDocsifyIgnoreTag(str); 158 | } 159 | 160 | index[slug] = { slug: slug, title: title, body: '' }; 161 | } else { 162 | if (tokenIndex === 0) { 163 | slug = router.toURL(path); 164 | index[slug] = { 165 | slug: slug, 166 | title: path !== '/' ? path.slice(1) : 'Home Page', 167 | body: token.text || '', 168 | }; 169 | } 170 | 171 | if (!slug) { 172 | return; 173 | } 174 | 175 | if (!index[slug]) { 176 | index[slug] = { slug: slug, title: '', body: '' }; 177 | } else if (index[slug].body) { 178 | token.text = getTableData(token); 179 | token.text = getListData(token); 180 | 181 | index[slug].body += '\n' + (token.text || ''); 182 | } else { 183 | token.text = getTableData(token); 184 | token.text = getListData(token); 185 | 186 | index[slug].body = index[slug].body 187 | ? index[slug].body + token.text 188 | : token.text; 189 | } 190 | } 191 | }); 192 | slugify.clear(); 193 | return index; 194 | } 195 | 196 | function ignoreDiacriticalMarks(keyword) { 197 | if (keyword && keyword.normalize) { 198 | return keyword.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); 199 | } 200 | return keyword; 201 | } 202 | 203 | /** 204 | * @param {String} query Search query 205 | * @returns {Array} Array of results 206 | */ 207 | function search(query) { 208 | var matchingResults = []; 209 | var data = []; 210 | Object.keys(INDEXS).forEach(function (key) { 211 | data = data.concat(Object.keys(INDEXS[key]).map(function (page) { return INDEXS[key][page]; })); 212 | }); 213 | 214 | query = query.trim(); 215 | var keywords = query.split(/[\s\-,\\/]+/); 216 | if (keywords.length !== 1) { 217 | keywords = [].concat(query, keywords); 218 | } 219 | 220 | var loop = function ( i ) { 221 | var post = data[i]; 222 | var matchesScore = 0; 223 | var resultStr = ''; 224 | var handlePostTitle = ''; 225 | var handlePostContent = ''; 226 | var postTitle = post.title && post.title.trim(); 227 | var postContent = post.body && post.body.trim(); 228 | var postUrl = post.slug || ''; 229 | 230 | if (postTitle) { 231 | keywords.forEach(function (keyword) { 232 | // From https://github.com/sindresorhus/escape-string-regexp 233 | var regEx = new RegExp( 234 | escapeHtml(ignoreDiacriticalMarks(keyword)).replace( 235 | /[|\\{}()[\]^$+*?.]/g, 236 | '\\$&' 237 | ), 238 | 'gi' 239 | ); 240 | var indexTitle = -1; 241 | var indexContent = -1; 242 | handlePostTitle = postTitle 243 | ? escapeHtml(ignoreDiacriticalMarks(postTitle)) 244 | : postTitle; 245 | handlePostContent = postContent 246 | ? escapeHtml(ignoreDiacriticalMarks(postContent)) 247 | : postContent; 248 | 249 | indexTitle = postTitle ? handlePostTitle.search(regEx) : -1; 250 | indexContent = postContent ? handlePostContent.search(regEx) : -1; 251 | 252 | if (indexTitle >= 0 || indexContent >= 0) { 253 | matchesScore += indexTitle >= 0 ? 3 : indexContent >= 0 ? 2 : 0; 254 | if (indexContent < 0) { 255 | indexContent = 0; 256 | } 257 | 258 | var start = 0; 259 | var end = 0; 260 | 261 | start = indexContent < 11 ? 0 : indexContent - 10; 262 | end = start === 0 ? 70 : indexContent + keyword.length + 60; 263 | 264 | if (postContent && end > postContent.length) { 265 | end = postContent.length; 266 | } 267 | 268 | var matchContent = 269 | handlePostContent && 270 | '...' + 271 | handlePostContent 272 | .substring(start, end) 273 | .replace( 274 | regEx, 275 | function (word) { return ("" + word + ""); } 276 | ) + 277 | '...'; 278 | 279 | resultStr += matchContent; 280 | } 281 | }); 282 | 283 | if (matchesScore > 0) { 284 | var matchingPost = { 285 | title: handlePostTitle, 286 | content: postContent ? resultStr : '', 287 | url: postUrl, 288 | score: matchesScore, 289 | }; 290 | 291 | matchingResults.push(matchingPost); 292 | } 293 | } 294 | }; 295 | 296 | for (var i = 0; i < data.length; i++) loop( i ); 297 | 298 | return matchingResults.sort(function (r1, r2) { return r2.score - r1.score; }); 299 | } 300 | 301 | function init(config, vm) { 302 | var isAuto = config.paths === 'auto'; 303 | var paths = isAuto ? getAllPaths(vm.router) : config.paths; 304 | 305 | var namespaceSuffix = ''; 306 | 307 | // only in auto mode 308 | if (paths.length && isAuto && config.pathNamespaces) { 309 | var path = paths[0]; 310 | 311 | if (Array.isArray(config.pathNamespaces)) { 312 | namespaceSuffix = 313 | config.pathNamespaces.filter( 314 | function (prefix) { return path.slice(0, prefix.length) === prefix; } 315 | )[0] || namespaceSuffix; 316 | } else if (config.pathNamespaces instanceof RegExp) { 317 | var matches = path.match(config.pathNamespaces); 318 | 319 | if (matches) { 320 | namespaceSuffix = matches[0]; 321 | } 322 | } 323 | var isExistHome = paths.indexOf(namespaceSuffix + '/') === -1; 324 | var isExistReadme = paths.indexOf(namespaceSuffix + '/README') === -1; 325 | if (isExistHome && isExistReadme) { 326 | paths.unshift(namespaceSuffix + '/'); 327 | } 328 | } else if (paths.indexOf('/') === -1 && paths.indexOf('/README') === -1) { 329 | paths.unshift('/'); 330 | } 331 | 332 | var expireKey = resolveExpireKey(config.namespace) + namespaceSuffix; 333 | var indexKey = resolveIndexKey(config.namespace) + namespaceSuffix; 334 | 335 | var isExpired = localStorage.getItem(expireKey) < Date.now(); 336 | 337 | INDEXS = JSON.parse(localStorage.getItem(indexKey)); 338 | 339 | if (isExpired) { 340 | INDEXS = {}; 341 | } else if (!isAuto) { 342 | return; 343 | } 344 | 345 | var len = paths.length; 346 | var count = 0; 347 | 348 | paths.forEach(function (path) { 349 | if (INDEXS[path]) { 350 | return count++; 351 | } 352 | 353 | Docsify.get(vm.router.getFile(path), false, vm.config.requestHeaders).then( 354 | function (result) { 355 | INDEXS[path] = genIndex(path, result, vm.router, config.depth); 356 | len === ++count && saveData(config.maxAge, expireKey, indexKey); 357 | } 358 | ); 359 | }); 360 | } 361 | 362 | /* eslint-disable no-unused-vars */ 363 | 364 | var NO_DATA_TEXT = ''; 365 | var options; 366 | 367 | function style() { 368 | var code = "\n.sidebar {\n padding-top: 0;\n}\n\n.search {\n margin-bottom: 20px;\n padding: 6px;\n border-bottom: 1px solid #eee;\n}\n\n.search .input-wrap {\n display: flex;\n align-items: center;\n}\n\n.search .results-panel {\n display: none;\n}\n\n.search .results-panel.show {\n display: block;\n}\n\n.search input {\n outline: none;\n border: none;\n width: 100%;\n padding: 0.6em 7px;\n font-size: inherit;\n border: 1px solid transparent;\n}\n\n.search input:focus {\n box-shadow: 0 0 5px var(--theme-color, #42b983);\n border: 1px solid var(--theme-color, #42b983);\n}\n\n.search input::-webkit-search-decoration,\n.search input::-webkit-search-cancel-button,\n.search input {\n -webkit-appearance: none;\n -moz-appearance: none;\n appearance: none;\n}\n\n.search input::-ms-clear {\n display: none;\n height: 0;\n width: 0;\n}\n\n.search .clear-button {\n cursor: pointer;\n width: 36px;\n text-align: right;\n display: none;\n}\n\n.search .clear-button.show {\n display: block;\n}\n\n.search .clear-button svg {\n transform: scale(.5);\n}\n\n.search h2 {\n font-size: 17px;\n margin: 10px 0;\n}\n\n.search a {\n text-decoration: none;\n color: inherit;\n}\n\n.search .matching-post {\n border-bottom: 1px solid #eee;\n}\n\n.search .matching-post:last-child {\n border-bottom: 0;\n}\n\n.search p {\n font-size: 14px;\n overflow: hidden;\n text-overflow: ellipsis;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n}\n\n.search p.empty {\n text-align: center;\n}\n\n.app-name.hide, .sidebar-nav.hide {\n display: none;\n}"; 369 | 370 | Docsify.dom.style(code); 371 | } 372 | 373 | function tpl(defaultValue) { 374 | if ( defaultValue === void 0 ) defaultValue = ''; 375 | 376 | var html = "
    \n \n
    \n \n \n \n \n \n
    \n
    \n
    \n "; 377 | var el = Docsify.dom.create('div', html); 378 | var aside = Docsify.dom.find('aside'); 379 | 380 | Docsify.dom.toggleClass(el, 'search'); 381 | Docsify.dom.before(aside, el); 382 | } 383 | 384 | function doSearch(value) { 385 | var $search = Docsify.dom.find('div.search'); 386 | var $panel = Docsify.dom.find($search, '.results-panel'); 387 | var $clearBtn = Docsify.dom.find($search, '.clear-button'); 388 | var $sidebarNav = Docsify.dom.find('.sidebar-nav'); 389 | var $appName = Docsify.dom.find('.app-name'); 390 | 391 | if (!value) { 392 | $panel.classList.remove('show'); 393 | $clearBtn.classList.remove('show'); 394 | $panel.innerHTML = ''; 395 | 396 | if (options.hideOtherSidebarContent) { 397 | $sidebarNav && $sidebarNav.classList.remove('hide'); 398 | $appName && $appName.classList.remove('hide'); 399 | } 400 | 401 | return; 402 | } 403 | 404 | var matchs = search(value); 405 | 406 | var html = ''; 407 | matchs.forEach(function (post) { 408 | html += ""; 409 | }); 410 | 411 | $panel.classList.add('show'); 412 | $clearBtn.classList.add('show'); 413 | $panel.innerHTML = html || ("

    " + NO_DATA_TEXT + "

    "); 414 | if (options.hideOtherSidebarContent) { 415 | $sidebarNav && $sidebarNav.classList.add('hide'); 416 | $appName && $appName.classList.add('hide'); 417 | } 418 | } 419 | 420 | function bindEvents() { 421 | var $search = Docsify.dom.find('div.search'); 422 | var $input = Docsify.dom.find($search, 'input'); 423 | var $inputWrap = Docsify.dom.find($search, '.input-wrap'); 424 | 425 | var timeId; 426 | 427 | /** 428 | Prevent to Fold sidebar. 429 | 430 | When searching on the mobile end, 431 | the sidebar is collapsed when you click the INPUT box, 432 | making it impossible to search. 433 | */ 434 | Docsify.dom.on( 435 | $search, 436 | 'click', 437 | function (e) { return ['A', 'H2', 'P', 'EM'].indexOf(e.target.tagName) === -1 && 438 | e.stopPropagation(); } 439 | ); 440 | Docsify.dom.on($input, 'input', function (e) { 441 | clearTimeout(timeId); 442 | timeId = setTimeout(function (_) { return doSearch(e.target.value.trim()); }, 100); 443 | }); 444 | Docsify.dom.on($inputWrap, 'click', function (e) { 445 | // Click input outside 446 | if (e.target.tagName !== 'INPUT') { 447 | $input.value = ''; 448 | doSearch(); 449 | } 450 | }); 451 | } 452 | 453 | function updatePlaceholder(text, path) { 454 | var $input = Docsify.dom.getNode('.search input[type="search"]'); 455 | 456 | if (!$input) { 457 | return; 458 | } 459 | 460 | if (typeof text === 'string') { 461 | $input.placeholder = text; 462 | } else { 463 | var match = Object.keys(text).filter(function (key) { return path.indexOf(key) > -1; })[0]; 464 | $input.placeholder = text[match]; 465 | } 466 | } 467 | 468 | function updateNoData(text, path) { 469 | if (typeof text === 'string') { 470 | NO_DATA_TEXT = text; 471 | } else { 472 | var match = Object.keys(text).filter(function (key) { return path.indexOf(key) > -1; })[0]; 473 | NO_DATA_TEXT = text[match]; 474 | } 475 | } 476 | 477 | function updateOptions(opts) { 478 | options = opts; 479 | } 480 | 481 | function init$1(opts, vm) { 482 | var keywords = vm.router.parse().query.s; 483 | 484 | updateOptions(opts); 485 | style(); 486 | tpl(keywords); 487 | bindEvents(); 488 | keywords && setTimeout(function (_) { return doSearch(keywords); }, 500); 489 | } 490 | 491 | function update(opts, vm) { 492 | updateOptions(opts); 493 | updatePlaceholder(opts.placeholder, vm.route.path); 494 | updateNoData(opts.noData, vm.route.path); 495 | } 496 | 497 | /* eslint-disable no-unused-vars */ 498 | 499 | var CONFIG = { 500 | placeholder: 'Type to search', 501 | noData: 'No Results!', 502 | paths: 'auto', 503 | depth: 2, 504 | maxAge: 86400000, // 1 day 505 | hideOtherSidebarContent: false, 506 | namespace: undefined, 507 | pathNamespaces: undefined, 508 | }; 509 | 510 | var install = function (hook, vm) { 511 | var util = Docsify.util; 512 | var opts = vm.config.search || CONFIG; 513 | 514 | if (Array.isArray(opts)) { 515 | CONFIG.paths = opts; 516 | } else if (typeof opts === 'object') { 517 | CONFIG.paths = Array.isArray(opts.paths) ? opts.paths : 'auto'; 518 | CONFIG.maxAge = util.isPrimitive(opts.maxAge) ? opts.maxAge : CONFIG.maxAge; 519 | CONFIG.placeholder = opts.placeholder || CONFIG.placeholder; 520 | CONFIG.noData = opts.noData || CONFIG.noData; 521 | CONFIG.depth = opts.depth || CONFIG.depth; 522 | CONFIG.hideOtherSidebarContent = 523 | opts.hideOtherSidebarContent || CONFIG.hideOtherSidebarContent; 524 | CONFIG.namespace = opts.namespace || CONFIG.namespace; 525 | CONFIG.pathNamespaces = opts.pathNamespaces || CONFIG.pathNamespaces; 526 | } 527 | 528 | var isAuto = CONFIG.paths === 'auto'; 529 | 530 | hook.mounted(function (_) { 531 | init$1(CONFIG, vm); 532 | !isAuto && init(CONFIG, vm); 533 | }); 534 | hook.doneEach(function (_) { 535 | update(CONFIG, vm); 536 | isAuto && init(CONFIG, vm); 537 | }); 538 | }; 539 | 540 | $docsify.plugins = [].concat(install, $docsify.plugins); 541 | 542 | }()); 543 | -------------------------------------------------------------------------------- /docs/assets/twitter-card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/analytics-debugger/ga4mp/b2e66f0709b2be583561db4277a25469930b736f/docs/assets/twitter-card.jpg -------------------------------------------------------------------------------- /docs/campaigns.md: -------------------------------------------------------------------------------- 1 | 2 | # Campaign Attribution 3 | We can pass and set our campaign attribution to any hit this way: 4 | 5 | ```javascript 6 | ga4track.trackEvent('page_view', { 7 | is_session_start: true, 8 | is_first_visit: true, 9 | campaign_medium: 'cpc', 10 | campaign_source: 'google', 11 | campaign_name: 'gclid' 12 | }) 13 | ``` 14 | ?> **Tip** or We could override the ```page_location``` to pass some utm variables. . 15 | 16 | | Parameter | | 17 | | ---------- | ------------------------------ | 18 | | campaign_medium | utm_medium| 19 | | campaign_source | utm_source | 20 | | campaign_name | utm_campaign | 21 | | campaign_content | utm_content | 22 | | campaign_term | utm_term | 23 | | campaign_id | utm_id | 24 | 25 | -------------------------------------------------------------------------------- /docs/coverpage.md: -------------------------------------------------------------------------------- 1 | # GA4MP 2 | 3 | > > An Open-Source Google Analytics 4 Client-Side Protocol Implementation 4 | 5 | - Lightweight < 4kb (compressed) 6 | - Full Protocol Documentation 7 | - Session Managing ( session_start, first_visits ) 8 | - Attribution Tracking 9 | - UMD/CJS/ESM Builds 10 | 11 | [Get Started](#main) 12 | [GitHub](https://github.com/analytics-debugger/ga4mp) 13 | [NPM](https://www.npmjs.com/package/@analytics-debugger/ga4mp) 14 | -------------------------------------------------------------------------------- /docs/demo.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | # Features 2 | You'll be in control of what you sent to Google without any of the limits of the current Measurement Protocol. 3 | 4 | If you run this in a browser, some details will be autopopulated: 5 | 6 | ```Client Hints``` 7 | 8 | |Name|Payload Key| 9 | | ---------- |------------------------------ | 10 | |_user_agent_architecture|```&uaa```| 11 | | _user_agent_bitness|```&uab```| 12 | | _user_agent_full_version_list|```&uafvl```| 13 | | _user_agent_mobile|```&uamb```| 14 | | _user_agent_model|```&uam```| 15 | | _user_agent_platform|```&uap```| 16 | | _user_agent_platform_version|```&uapv```| 17 | | _user_agent_wait|```&uaW```| 18 | | _user_agent_wow64|```&uaw```| 19 | 20 | 21 | and ```Page``` Related Dimensions 22 | 23 | |Name|Payload Key| 24 | | ---------- |------------------------------ | 25 | |page_location|```&dl```| 26 | | page_referrer|```&dr```| 27 | | page_title|```&dt```| 28 | | language|```&udl```| 29 | | screen_resolution|```&sr```| 30 | 31 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 14 | GA4MP - An Open-Source Google Analytics Client-Side Protocol Implementation 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 28 | 29 | 30 | 31 | 32 | 33 | 46 | 47 | 48 | 64 | 65 | 66 | 67 |
    68 | 69 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | 2 | # Installation 3 | # Loading the library 4 | 5 | #### **ES6 Imports** 6 | ```javascript 7 | import ga4mp from '@analytics-debugger/ga4mp' 8 | const ga4track = ga4mp(["G-THYNGSTER"], { 9 | user_id: undefined, 10 | non_personalized_ads: true, 11 | debug: true 12 | }); 13 | ``` 14 | #### **Browser** 15 | ```javascript 16 | 4 | 5 | 6 | 7 | 76 | 77 | 78 | 79 | 80 |

     

    81 |

    GA4MP Library 0.0.1-alpha.0 [2022-11-09] ( David Vallejo @thyng )

    82 |

    Official Website :: https://ga4mp.dev

    83 |
    84 |

    Initializing the tracker

    85 |
    86 |
    <script src="https://cdn.jsdelivr.net/npm/@analytics-debugger/ga4mp@latest/dist/ga4mp.umd.min.js"></script>
     87 | <script>
     88 |             const ga4track = ga4mp(["G-THYNGSTER"], {
     89 |              // client_id: '123123123.123123123123', // autogenerate
     90 |                 user_id: undefined,
     91 |                 non_personalized_ads: true,
     92 |                debug: true
     93 |             });            
     94 | </script>
     95 | 
    96 | 97 |
    98 |

    Setting an User Property the tracker

    99 |
    100 |
    ga4track.setUserProperty('is_premium_user', 'yes')
    101 | 102 |
    103 |

    Setting a persistent Event Parameter

    104 |
    105 |
    ga4track.setEventParameter('page_type', 'home', true)
    106 | 107 |
    108 |

    Set the userId a persistent Event Parameter

    109 |
    110 |
    ga4track.setUserId('92932837723')
    111 | 112 |
    113 |

    Sending a page_view

    114 | 115 | 116 |
    117 |
    ga4track.trackEvent('page_view', {})        
    118 |     
    119 | 120 | 121 |
    122 |

    Send a pageview that trigger a new session and a first visit

    123 |
    124 |
    ga4track.trackEvent('page_view', {
    125 |     is_session_start: true, 
    126 |     is_first_visit: true,            
    127 | })                
    128 | 129 | 130 |
    131 |

    Send a pageview that trigger a new session and set the campaign attribution

    132 |
    133 |
    ga4track.trackEvent('page_view', {
    134 |     is_session_start: true, 
    135 |     campaign_medium: 'cpc',
    136 |     campaign_source: 'google',                    
    137 |     campaign_name: 'gclid'         
    138 | })      
    139 | 
    140 | 141 | 142 |
    143 | 144 | 145 |

    Sending Ecommerce

    146 | 147 | 148 |
    149 |
    ga4track.trackEvent('view_item', {items: [{
    150 |     id: '123123',
    151 |     name: 'Demo Product',
    152 |     brand: 'Analytics Debugger',
    153 |     item_id: '123123',
    154 |     item_name: 'Demo Product',
    155 |     item_brand: 'Analytics Debugger',
    156 |     item_category: 'demo',
    157 |     item_category2: '1',
    158 |     item_category3: undefined,
    159 |     item_category4: undefined,
    160 |     item_category5: undefined,
    161 |     item_variant: undefined,
    162 |     discount: undefined,
    163 |     quantity: undefined,
    164 |     price: '180.00',
    165 |     currency: 'EUR',
    166 |     coupon: undefined,
    167 |     item_list_name: undefined,
    168 |     item_list_id: undefined,
    169 |     index: undefined,
    170 |     custom_item_parameter: 'no data'
    171 | }]});           
    172 |     
    173 | 174 | 175 |
    176 | 177 |

    Sending an event

    178 | 179 | 180 |
    181 |
    ga4track.trackEvent('click_outgoing', {
    182 |   clicked_url: 'https://www.thyngster.com',
    183 |   time: 12323
    184 | })
    185 | 186 |
    187 | 188 | 189 | 190 | 191 | -------------------------------------------------------------------------------- /docs/ports.md: -------------------------------------------------------------------------------- 1 | Porting this to other languages is planned once everything has been properly tested. Also since the code and docs would be open, anyone should be able to port this to 2 | any other language -------------------------------------------------------------------------------- /docs/sidebar.md: -------------------------------------------------------------------------------- 1 | - [GA4MP](/) 2 | - [Installation](/installation) 3 | - [Features](/features) 4 | - [Options](/options) 5 | - [Tracking](/tracking) 6 | - [Campaigns](/campaigns) 7 | - [Ports](/ports) 8 | - [API](/api) 9 | - **Demo** 10 | - [PlayGround](https://ga4mp.dev/playground) 11 | - **Links** 12 | - [Github](https://github.com/thyngster) 13 | - [Twitter](https://www.twitter.com/thyng) 14 | - [Blog](https://www.thyngster.com) 15 | 16 | -------------------------------------------------------------------------------- /docs/tracking.md: -------------------------------------------------------------------------------- 1 | 2 | # Tracking 3 | # Page Views 4 | 5 | ```javascript 6 | ga4track.trackEvent('page_view') 7 | ``` 8 | 9 | # Events 10 | 11 | ```javascript 12 | ga4track.trackEvent('click_outgoing', { 13 | clicked_url: 'https://www.thyngster.com', 14 | time: 12323 15 | }) 16 | ``` 17 | 18 | # Ecommerce 19 | 20 | ```javascript 21 | ga4track.trackEvent('view_item', {items: [{ 22 | item_id: '615035', 23 | item_name: 'Asics Nimbus 23', 24 | item_brand: 'ASICS', 25 | item_category: 'men', 26 | item_category2: 'shoes', 27 | item_category3: undefined, 28 | item_category4: undefined, 29 | item_category5: undefined, 30 | item_variant: 'blue', 31 | discount: undefined, 32 | quantity: undefined, 33 | price: '180.00', 34 | currency: 'EUR', 35 | coupon: undefined, 36 | item_list_name: undefined, 37 | item_list_id: undefined, 38 | index: undefined, 39 | is_in_stock: 'yes', 40 | season: 'summer', 41 | size: '12', 42 | color: 'blue', 43 | type: 'trail running' 44 | }]}); 45 | 46 | ``` 47 | # User Properties 48 | These can be set using the following code, and they will be attached to all the subsecuent hits. 49 | ```javascript 50 | ga4track.setUserProperty('premium_member', 'yes') 51 | ``` 52 | 53 | # Event parameters 54 | All the keys passes to tracker that are not reserved will be parsed as custom parameters, in the case we want to have an event parameter attached to 55 | all the next events we can do the following 56 | ```javascript 57 | ga4track.setEventsParameter('premium_member', 'yes') 58 | ``` 59 | 60 | # Event Values Callbacks 61 | We can pass a function as a parameter and the value returned will be set as the value. 62 | 63 | ```javascript 64 | ga4track.setEventsParameter('timestamp', ()=>{ 65 | return new Date() * 1 66 | }) 67 | ``` 68 | 69 | # Marking a new session 70 | Pass ```is_session_start``` as a paremeter to any call 71 | 72 | ```javascript 73 | ga4track.trackEvent('page_view', { is_session_start: true}) 74 | ``` 75 | # Marking a first Visit 76 | Pass ```is_first_visit``` as a paremeter to any call 77 | ```javascript 78 | ga4track.trackEvent('page_view', { is_first_visit: true}) 79 | ``` 80 | 81 | # Tracking a conversion 82 | ```javascript 83 | ga4track.trackEvent('lead_sent', { 84 | is_conversion : true 85 | }) 86 | ``` 87 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | GA4MP - Demo 3 | 4 | 5 | 6 | 7 | 84 | 85 | 86 | 87 | 88 |

     

    89 |

    GA4MP Library 0.0.3 [2023-03-05] ( David Vallejo @thyng )

    90 |

    Official Website :: https://ga4mp.dev

    91 |
    92 |

    Initializing the tracker

    93 |
    94 |
    <script src="https://cdn.jsdelivr.net/npm/@analytics-debugger/ga4mp@latest/dist/ga4mp.umd.min.js"></script>
     95 | <script>
     96 |             const ga4track = ga4mp(["G-THYNGSTER"], {
     97 |              // client_id: '123123123.123123123123', // autogenerate
     98 |                 user_id: undefined,
     99 |                 non_personalized_ads: true,
    100 |                debug: true
    101 |             });            
    102 | </script>
    103 | 
    104 | 105 |
    106 |

    Setting an User Property the tracker

    107 |
    108 |
    ga4track.setUserProperty('is_premium_user', 'yes')
    109 | 110 |
    111 |

    Setting a persistent Event Parameter

    112 |
    113 |
    ga4track.setEventParameter('page_type', 'home', true)
    114 | 115 |
    116 |

    Set the userId a persistent Event Parameter

    117 |
    118 |
    ga4track.setUserId('92932837723')
    119 | 120 |
    121 |

    Sending a page_view

    122 | 123 | 124 |
    125 |
    ga4track.trackEvent('page_view', {})        
    126 |     
    127 | 128 | 129 |
    130 |

    Send a pageview that trigger a new session and a first visit

    131 |
    132 |
    ga4track.trackEvent('page_view', {
    133 |     is_session_start: true, 
    134 |     is_first_visit: true,            
    135 | })                
    136 | 137 | 138 |
    139 |

    Send a pageview that trigger a new session and set the campaign attribution

    140 |
    141 |
    ga4track.trackEvent('page_view', {
    142 |     is_session_start: true, 
    143 |     campaign_medium: 'cpc',
    144 |     campaign_source: 'google',                    
    145 |     campaign_name: 'gclid'         
    146 | })      
    147 | 148 | 149 |
    150 | 151 |

    Virtual PageView

    152 |
    153 |
    ga4track.trackEvent('page_view', {
    154 |         page_location: 'https://www.google.es'
    155 | })      
    156 | 157 | 158 |
    159 |

    Sending Ecommerce

    160 | 161 | 162 |
    163 |
    ga4track.trackEvent('view_item', {items: [{
    164 |     id: '123123',
    165 |     name: 'Demo Product',
    166 |     brand: 'Analytics Debugger',
    167 |     item_id: '123123',
    168 |     item_name: 'Demo Product',
    169 |     item_brand: 'Analytics Debugger',
    170 |     item_category: 'demo',
    171 |     item_category2: '1',
    172 |     item_category3: undefined,
    173 |     item_category4: undefined,
    174 |     item_category5: undefined,
    175 |     item_variant: undefined,
    176 |     discount: undefined,
    177 |     quantity: undefined,
    178 |     price: '180.00',
    179 |     currency: 'EUR',
    180 |     coupon: undefined,
    181 |     item_list_name: undefined,
    182 |     item_list_id: undefined,
    183 |     index: undefined,
    184 |     custom_item_parameter: 'no data'
    185 | }]});           
    186 |     
    187 | 188 | 189 |
    190 | 191 |

    Sending an event

    192 | 193 | 194 |
    195 |
    ga4track.trackEvent('click_outgoing', {
    196 |   clicked_url: 'https://www.thyngster.com',
    197 |   time: 12323
    198 | })
    199 | 200 |
    201 | 202 | 203 | 204 | 205 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // jest.config.js 2 | module.exports = { 3 | //verbose: true, 4 | testMatch: ["**/__tests__/*.js"] 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@analytics-debugger/ga4mp", 3 | "version": "0.0.8", 4 | "description": "An Open-Source Google Analytics 4 Client-Side Protocol Implementation", 5 | "main": "dist/ga4mp.min.js", 6 | "module": "dist/ga4mp.esm.min.js", 7 | "browser": "dist/ga4mp.min.js", 8 | "devDependencies": { 9 | "jest": "^26.6.3" 10 | }, 11 | "scripts": { 12 | "build": "rollup -c --bundleConfigAsCjs", 13 | "dev": "rollup -c --watch --bundleConfigAsCjs", 14 | "test": "jest", 15 | "devtest": "jest --watch", 16 | "prepublish:alpha": "npm version prerelease --preid=alpha && npm run build", 17 | "prepublish:beta": "npm version prerelease --preid=beta && npm run build", 18 | "prepublish:patch": "npm version patch --force && npm run build", 19 | "prepublish:minor": "npm version minor --force && npm run build", 20 | "prepublish:major": "npm version major --force && npm run build", 21 | "publish:public": "npm publish --access public" 22 | }, 23 | "files": [ 24 | "dist" 25 | ], 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/analytics-debugger/ga4mp" 29 | }, 30 | "keywords": [ 31 | "google", 32 | "google tag", 33 | "gtag", 34 | "ga4", 35 | "google analytics 4", 36 | "server side", 37 | "ga4", 38 | "analytics", 39 | "measurement protocol" 40 | ], 41 | "author": { 42 | "name": "David Vallejo ", 43 | "email": "thyngster@gmail.com", 44 | "url": "https://www.thyngster.com" 45 | }, 46 | "license": "MIT", 47 | "bugs": { 48 | "url": "https://github.com/analytics-debugger/ga4mp/issues" 49 | }, 50 | "homepage": "https://www.thyngster.com", 51 | "funding": { 52 | "type": "individual", 53 | "url": "https://github.com/sponsors/thyngster" 54 | }, 55 | "dependencies": { 56 | "@babel/core": "^7.20.5", 57 | "@babel/plugin-transform-object-assign": "^7.18.6", 58 | "@babel/preset-env": "^7.20.2", 59 | "@rollup/plugin-babel": "^6.0.3", 60 | "@rollup/plugin-json": "^6.0.0", 61 | "@rollup/plugin-node-resolve": "^15.0.1", 62 | "@rollup/plugin-terser": "^0.2.0", 63 | "rollup": "^3.7.1", 64 | "rollup-plugin-license": "^3.0.1" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import {babel} from '@rollup/plugin-babel'; 2 | import terser from "@rollup/plugin-terser"; 3 | import resolve from "@rollup/plugin-node-resolve"; 4 | const path = require('path'); 5 | const license = require('rollup-plugin-license'); 6 | 7 | const terserOptions = { 8 | compress: { 9 | passes: 2 10 | } 11 | }; 12 | 13 | module.exports = [ 14 | { 15 | input: "src/index.js", 16 | output: [ 17 | { 18 | file: "dist/ga4mp.amd.js", 19 | format: "amd" 20 | }, 21 | { 22 | file: "dist/ga4mp.amd.min.js", 23 | format: "amd", 24 | plugins: [terser(terserOptions)] 25 | }, 26 | { 27 | file: "dist/ga4mp.iife.js", 28 | name: "ga4mp", 29 | format: "iife" 30 | }, 31 | { 32 | file: "dist/ga4mp.iife.min.js", 33 | name: "ga4mp", 34 | format: "iife", 35 | plugins: [terser(terserOptions)] 36 | }, 37 | { 38 | file: "dist/ga4mp.umd.js", 39 | name: "ga4mp", 40 | format: "umd" 41 | }, 42 | { 43 | file: "dist/ga4mp.umd.min.js", 44 | name: "ga4mp", 45 | format: "umd", 46 | plugins: [terser(terserOptions)] 47 | } 48 | ], 49 | plugins: [ 50 | license({ 51 | banner: `/*! 52 | * 53 | * <%= pkg.name %> <%= pkg.version %> 54 | * https://github.com/analytics-debugger/ga4mp 55 | * 56 | * Copyright (c) David Vallejo (https://www.thyngster.com). 57 | * This source code is licensed under the MIT license found in the 58 | * LICENSE file in the root directory of this source tree. 59 | * 60 | */ 61 | `, 62 | }), 63 | resolve(), 64 | babel({ 65 | exclude: "node_modules/**" 66 | }) 67 | ] 68 | }, 69 | { 70 | input: "src/index.js", 71 | output: [ 72 | { 73 | file: "dist/ga4mp.esm.js", 74 | format: "esm" 75 | }, 76 | { 77 | file: "dist/ga4mp.esm.min.js", 78 | format: "esm", 79 | plugins: [terser(terserOptions)] 80 | } 81 | ] 82 | } 83 | ]; 84 | -------------------------------------------------------------------------------- /src/ga4mp.js: -------------------------------------------------------------------------------- 1 | import { 2 | trim, 3 | isNumber, 4 | isFunction, 5 | getEnvironment, 6 | randomInt, 7 | timestampInSeconds, 8 | sanitizeValue, 9 | } from './modules/helpers' 10 | 11 | import { ga4Schema, ecommerceEvents } from './modules/ga4Schema' 12 | import { sendRequest } from './modules/request' 13 | import clientHints from './modules/clientHints' 14 | import pageDetails from './modules/pageInfo' 15 | 16 | const version = '0.0.8' 17 | 18 | /** 19 | * Main Class Function 20 | * @param {array|string} measurement_ids 21 | * @param {object} config 22 | * @returns 23 | */ 24 | 25 | const ga4mp = function (measurement_ids, config = {}) { 26 | if (!measurement_ids) 27 | throw 'Tracker initialization aborted: missing tracking ids' 28 | const internalModel = Object.assign( 29 | { 30 | version, 31 | debug: false, 32 | mode: getEnvironment() || 'browser', 33 | measurement_ids: null, 34 | queueDispatchTime: 5000, 35 | queueDispatchMaxEvents: 10, 36 | queue: [], 37 | eventParameters: {}, 38 | persistentEventParameters: {}, 39 | userProperties: {}, 40 | user_agent: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/${version}]`, 41 | user_ip_address: null, 42 | hooks: { 43 | beforeLoad: () => {}, 44 | beforeRequestSend: () => {}, 45 | }, 46 | endpoint: 'https://www.google-analytics.com/g/collect', 47 | payloadData: {}, 48 | }, 49 | config 50 | ) 51 | 52 | // Initialize Tracker Data 53 | internalModel.payloadData.protocol_version = 2 54 | internalModel.payloadData.tracking_id = Array.isArray(measurement_ids) 55 | ? measurement_ids 56 | : [measurement_ids] 57 | internalModel.payloadData.client_id = config.client_id 58 | ? config.client_id 59 | : [randomInt(), timestampInSeconds()].join('.') 60 | internalModel.payloadData._is_debug = config.debug ? 1 : undefined 61 | internalModel.payloadData.non_personalized_ads = config.non_personalized_ads 62 | ? 1 63 | : undefined 64 | internalModel.payloadData.hit_count = 1 65 | 66 | // Initialize Session Data 67 | internalModel.payloadData.session_id = config.session_id 68 | ? config.session_id 69 | : timestampInSeconds() 70 | internalModel.payloadData.session_number = config.session_number 71 | ? config.session_number 72 | : 1 73 | 74 | // Initialize User Data 75 | internalModel.payloadData.user_id = config.user_id 76 | ? trim(config.user_id, 256) 77 | : undefined 78 | internalModel.payloadData.user_ip_address = config.user_ip_address 79 | ? config.user_ip_address 80 | : undefined 81 | internalModel.userAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 [GA4MP/${version}]` 82 | 83 | // Initialize Tracker Data 84 | if (internalModel === 'node' && config.user_agent) { 85 | internalModel.user_agent = config.user_agent 86 | } 87 | // Grab data only browser data 88 | if (internalModel.mode === 'browser') { 89 | const pageData = pageDetails() 90 | if (pageData) { 91 | internalModel.payloadData = Object.assign( 92 | internalModel.payloadData, 93 | pageData 94 | ) 95 | } 96 | } 97 | /** 98 | * Dispatching Queue 99 | * TO-DO 100 | */ 101 | const dispatchQueue = () => { 102 | internalModel.queue = [] 103 | } 104 | 105 | /** 106 | * Grab current ClientId 107 | * @returns string 108 | */ 109 | const getClientId = () => { 110 | return internalModel.payloadData.client_id 111 | } 112 | 113 | /** 114 | * Grab current Session ID 115 | * @returns string 116 | */ 117 | const getSessionId = () => { 118 | return internalModel.payloadData.session_id 119 | } 120 | 121 | /** 122 | * Set an Sticky Event Parameter, it wil be attached to all events 123 | * @param {string} key 124 | * @param {string|number|Fn} value 125 | * @returns 126 | */ 127 | const setEventsParameter = (key, value) => { 128 | if (isFunction(value)) { 129 | try { 130 | value = value() 131 | } catch (e) {} 132 | } 133 | key = sanitizeValue(key, 40) 134 | value = sanitizeValue(value, 100) 135 | internalModel['persistentEventParameters'][key] = value 136 | } 137 | 138 | /** 139 | * setUserProperty 140 | * @param {*} key 141 | * @param {*} value 142 | * @returns 143 | */ 144 | const setUserProperty = (key, value) => { 145 | key = sanitizeValue(key, 24) 146 | value = sanitizeValue(value, 36) 147 | internalModel['userProperties'][key] = value 148 | } 149 | 150 | /** 151 | * Generate Payload 152 | * @param {object} customEventParameters 153 | */ 154 | const buildPayload = (eventName, customEventParameters) => { 155 | const payload = {} 156 | if (internalModel.payloadData.hit_count === 1) 157 | internalModel.payloadData.session_engaged = 1 158 | 159 | Object.entries(internalModel.payloadData).forEach((pair) => { 160 | const key = pair[0] 161 | const value = pair[1] 162 | if (ga4Schema[key]) { 163 | payload[ga4Schema[key]] = 164 | typeof value === 'boolean' ? +value : value 165 | } 166 | }) 167 | // GA4 Will have different Limits based on "unknown" rules 168 | // const itemsLimit = isP ? 27 : 10 169 | const eventParameters = Object.assign( 170 | JSON.parse(JSON.stringify(internalModel.persistentEventParameters)), 171 | JSON.parse(JSON.stringify(customEventParameters)) 172 | ) 173 | eventParameters.event_name = eventName 174 | Object.entries(eventParameters).forEach((pair) => { 175 | const key = pair[0] 176 | const value = pair[1] 177 | if ( 178 | key === 'items' && 179 | ecommerceEvents.indexOf(eventName) > -1 && 180 | Array.isArray(value) 181 | ) { 182 | // only 200 items per event 183 | let items = value.slice(0, 200) 184 | for (let i = 0; i < items.length; i++) { 185 | if (items[i]) { 186 | const item = { 187 | core: {}, 188 | custom: {}, 189 | } 190 | Object.entries(items[i]).forEach((pair) => { 191 | if (ga4Schema[pair[0]]) { 192 | if (typeof pair[1] !== 'undefined') 193 | item.core[ga4Schema[pair[0]]] = pair[1] 194 | } else item.custom[pair[0]] = pair[1] 195 | }) 196 | let productString = 197 | Object.entries(item.core) 198 | .map((v) => { 199 | return v[0] + v[1] 200 | }) 201 | .join('~') + 202 | '~' + 203 | Object.entries(item.custom) 204 | .map((v, i) => { 205 | var customItemParamIndex = 206 | 10 > i 207 | ? '' + i 208 | : String.fromCharCode(65 + i - 10) 209 | return `k${customItemParamIndex}${v[0]}~v${customItemParamIndex}${v[1]}` 210 | }) 211 | .join('~') 212 | payload[`pr${i + 1}`] = productString 213 | } 214 | } 215 | } else { 216 | if (ga4Schema[key]) { 217 | payload[ga4Schema[key]] = 218 | typeof value === 'boolean' ? +value : value 219 | } else { 220 | payload[(isNumber(value) ? 'epn.' : 'ep.') + key] = value 221 | } 222 | } 223 | }) 224 | Object.entries(internalModel.userProperties).forEach((pair) => { 225 | const key = pair[0] 226 | const value = pair[1] 227 | if (ga4Schema[key]) { 228 | payload[ga4Schema[key]] = 229 | typeof value === 'boolean' ? +value : value 230 | } else { 231 | payload[(isNumber(value) ? 'upn.' : 'up.') + key] = value 232 | } 233 | }) 234 | return payload 235 | } 236 | 237 | /** 238 | * setUserId 239 | * @param {string} value 240 | * @returns 241 | */ 242 | const setUserId = (value) => { 243 | internalModel.payloadData.user_id = sanitizeValue(value, 256) 244 | } 245 | 246 | /** 247 | * Track Event 248 | * @param {string} eventName 249 | * @param {object} eventParameters 250 | * @param {boolean} forceDispatch 251 | */ 252 | const getHitIndex = () => { 253 | return internalModel.payloadData.hit_count 254 | } 255 | const trackEvent = ( 256 | eventName, 257 | eventParameters = {}, 258 | sessionControl = {}, 259 | forceDispatch = true 260 | ) => { 261 | // We want to wait for the CH Promise to fullfill 262 | clientHints(internalModel?.mode).then((ch) => { 263 | if (ch) { 264 | internalModel.payloadData = Object.assign( 265 | internalModel.payloadData, 266 | ch 267 | ) 268 | } 269 | const payload = buildPayload(eventName, eventParameters, sessionControl) 270 | if (payload && forceDispatch) { 271 | for (let i = 0; i < payload.tid.length; i++) { 272 | let r = JSON.parse(JSON.stringify(payload)) 273 | r.tid = payload.tid[i] 274 | sendRequest(internalModel.endpoint, r, internalModel.mode, { 275 | user_agent: internalModel?.user_agent, 276 | }) 277 | } 278 | internalModel.payloadData.hit_count++ 279 | } else { 280 | const eventsCount = internalModel.queue.push(event) 281 | if (eventsCount >= internalModel.queueDispatchMaxEvents) { 282 | dispatchQueue() 283 | } 284 | } 285 | }) 286 | } 287 | return { 288 | version: internalModel.version, 289 | mode: internalModel.mode, 290 | getHitIndex, 291 | getSessionId, 292 | getClientId, 293 | setUserProperty, 294 | setEventsParameter, 295 | setUserId, 296 | trackEvent, 297 | } 298 | } 299 | 300 | export default ga4mp 301 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ga4mp from './ga4mp' 2 | export default ga4mp 3 | -------------------------------------------------------------------------------- /src/modules/clientHints.js: -------------------------------------------------------------------------------- 1 | const clientHints = (mode) => { 2 | if (mode === 'node' || typeof(window) === 'undefined' || typeof(window) !== 'undefined' && !('navigator' in window)) { 3 | return new Promise((resolve) => { 4 | resolve(null) 5 | }) 6 | } 7 | if (!navigator?.userAgentData?.getHighEntropyValues) 8 | return new Promise((resolve) => { 9 | resolve(null) 10 | }) 11 | return navigator.userAgentData 12 | .getHighEntropyValues([ 13 | 'platform', 14 | 'platformVersion', 15 | 'architecture', 16 | 'model', 17 | 'uaFullVersion', 18 | 'bitness', 19 | 'fullVersionList', 20 | 'wow64', 21 | ]) 22 | .then((d) => { 23 | return { 24 | _user_agent_architecture: d.architecture, 25 | _user_agent_bitness: d.bitness, 26 | _user_agent_full_version_list: encodeURIComponent( 27 | (Object.values(d.fullVersionList) || navigator?.userAgentData?.brands) 28 | .map((h) => { 29 | return [h.brand, h.version].join(';') 30 | }) 31 | .join('|') 32 | ), 33 | _user_agent_mobile: d.mobile ? 1 : 0, 34 | _user_agent_model: d.model || navigator?.userAgentData?.mobile, 35 | _user_agent_platform: d.platform || navigator?.userAgentData?.platform, 36 | _user_agent_platform_version: d.platformVersion, 37 | _user_agent_wow64: d.wow64 ? 1 : 0, 38 | } 39 | }) 40 | } 41 | export default clientHints 42 | -------------------------------------------------------------------------------- /src/modules/ga4Schema.js: -------------------------------------------------------------------------------- 1 | export const ga4Schema = { 2 | _em: 'em', 3 | event_name: 'en', 4 | protocol_version: 'v', 5 | _page_id: '_p', 6 | _is_debug: '_dbg', 7 | tracking_id: 'tid', 8 | hit_count: '_s', 9 | user_id: 'uid', 10 | client_id: 'cid', 11 | page_location: 'dl', 12 | language: 'ul', 13 | firebase_id: '_fid', 14 | traffic_type: 'tt', 15 | ignore_referrer: 'ir', 16 | screen_resolution: 'sr', 17 | global_developer_id_string: 'gdid', 18 | redact_device_info: '_rdi', 19 | geo_granularity: '_geo', 20 | _is_passthrough_cid: 'gtm_up', 21 | _is_linker_valid: '_glv', 22 | _user_agent_architecture: 'uaa', 23 | _user_agent_bitness: 'uab', 24 | _user_agent_full_version_list: 'uafvl', 25 | _user_agent_mobile: 'uamb', 26 | _user_agent_model: 'uam', 27 | _user_agent_platform: 'uap', 28 | _user_agent_platform_version: 'uapv', 29 | _user_agent_wait: 'uaW', 30 | _user_agent_wow64: 'uaw', 31 | error_code: 'ec', 32 | session_id: 'sid', 33 | session_number: 'sct', 34 | session_engaged: 'seg', 35 | page_referrer: 'dr', 36 | page_title: 'dt', 37 | currency: 'cu', 38 | campaign_content: 'cc', 39 | campaign_id: 'ci', 40 | campaign_medium: 'cm', 41 | campaign_name: 'cn', 42 | campaign_source: 'cs', 43 | campaign_term: 'ck', 44 | engagement_time_msec: '_et', 45 | event_developer_id_string: 'edid', 46 | is_first_visit: '_fv', 47 | is_new_to_site: '_nsi', 48 | is_session_start: '_ss', 49 | is_conversion: '_c', 50 | euid_mode_enabled: 'ecid', 51 | non_personalized_ads: '_npa', 52 | create_google_join: 'gaz', 53 | is_consent_update: 'gsu', 54 | user_ip_address: '_uip', 55 | google_consent_state: 'gcs', 56 | google_consent_update: 'gcu', 57 | us_privacy_string: 'us_privacy', 58 | document_location: 'dl', 59 | document_path: 'dp', 60 | document_title: 'dt', 61 | document_referrer: 'dr', 62 | user_language: 'ul', 63 | document_hostname: 'dh', 64 | item_id: 'id', 65 | item_name: 'nm', 66 | item_brand: 'br', 67 | item_category: 'ca', 68 | item_category2: 'c2', 69 | item_category3: 'c3', 70 | item_category4: 'c4', 71 | item_category5: 'c5', 72 | item_variant: 'va', 73 | price: 'pr', 74 | quantity: 'qt', 75 | coupon: 'cp', 76 | item_list_name: 'ln', 77 | index: 'lp', 78 | item_list_id: 'li', 79 | discount: 'ds', 80 | affiliation: 'af', 81 | promotion_id: 'pi', 82 | promotion_name: 'pn', 83 | creative_name: 'cn', 84 | creative_slot: 'cs', 85 | location_id: 'lo', 86 | // legacy ecommerce 87 | id: 'id', 88 | name: 'nm', 89 | brand: 'br', 90 | variant: 'va', 91 | list_name: 'ln', 92 | list_position: 'lp', 93 | list: 'ln', 94 | position: 'lp', 95 | creative: 'cn', 96 | } 97 | 98 | export const ecommerceEvents = [ 99 | 'add_payment_info', 100 | 'add_shipping_info', 101 | 'add_to_cart', 102 | 'remove_from_cart', 103 | 'view_cart', 104 | 'begin_checkout', 105 | 'select_item', 106 | 'view_item_list', 107 | 'select_promotion', 108 | 'view_promotion', 109 | 'purchase', 110 | 'refund', 111 | 'view_item', 112 | 'add_to_wishlist', 113 | ] 114 | -------------------------------------------------------------------------------- /src/modules/helpers.js: -------------------------------------------------------------------------------- 1 | export const trim = (str, chars) => { 2 | if (typeof str === 'string') { 3 | return str.substring(0, chars) 4 | } else { 5 | return str 6 | } 7 | } 8 | export const isFunction = (val) => { 9 | if (!val) return false 10 | return ( 11 | Object.prototype.toString.call(val) === '[object Function]' || 12 | (typeof val === 'function' && 13 | Object.prototype.toString.call(val) !== '[object RegExp]') 14 | ) 15 | } 16 | export const isNumber = (val) => 'number' === typeof val && !isNaN(val) 17 | export const isObject = (val) => 18 | val != null && typeof val === 'object' && Array.isArray(val) === false 19 | export const isString = (val) => val != null && typeof val === 'string' 20 | export const randomInt = () => 21 | Math.floor(Math.random() * (2147483647 - 0 + 1) + 0) 22 | export const timestampInSeconds = () => Math.floor((new Date() * 1) / 1000) 23 | export const getEnvironment = () => { 24 | let env 25 | if (typeof window !== 'undefined' && typeof window.document !== 'undefined') 26 | env = 'browser' 27 | else if ( 28 | typeof process !== 'undefined' && 29 | process.versions != null && 30 | process.versions.node != null 31 | ) 32 | env = 'node' 33 | return env 34 | } 35 | /** 36 | * Logger Function 37 | * @param {string} message 38 | * @param {object} data 39 | */ 40 | 41 | export const log = (message, data) => { 42 | console.log('[GA4MP-LOG]', message, data) 43 | } 44 | 45 | /** 46 | * Populate Page Related Details 47 | */ 48 | export const pageDetails = () => { 49 | return { 50 | page_location: document.location.href, 51 | page_referrer: document.referrer, 52 | page_title: document.title, 53 | language: ( 54 | (navigator && (navigator.language || navigator.browserLanguage)) || 55 | '' 56 | ).toLowerCase(), 57 | screen_resolution: 58 | (window.screen ? window.screen.width : 0) + 59 | 'x' + 60 | (window.screen ? window.screen.height : 0), 61 | } 62 | } 63 | 64 | /** 65 | * Function to sanitize values based on GA4 Model Limits 66 | * @param {string} val 67 | * @param {integer} maxLength 68 | * @returns 69 | */ 70 | 71 | export const sanitizeValue = (val, maxLength) => { 72 | // Trim a key-value pair value based on GA4 limits 73 | /*eslint-disable */ 74 | try { 75 | val = val.toString() 76 | } catch (e) {} 77 | /*eslint-enable */ 78 | if (!isString(val) || !maxLength || !isNumber(maxLength)) return val 79 | return trim(val, maxLength) 80 | } 81 | -------------------------------------------------------------------------------- /src/modules/pageInfo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Populate Page Related Details 3 | */ 4 | const pageDetails = () => { 5 | return { 6 | page_location: document.location.href, 7 | page_referrer: document.referrer, 8 | page_title: document.title, 9 | language: ( 10 | (navigator && (navigator.language || navigator.browserLanguage)) || 11 | '' 12 | ).toLowerCase(), 13 | screen_resolution: 14 | (window.screen ? window.screen.width : 0) + 15 | 'x' + 16 | (window.screen ? window.screen.height : 0), 17 | } 18 | } 19 | export default pageDetails 20 | -------------------------------------------------------------------------------- /src/modules/request.js: -------------------------------------------------------------------------------- 1 | export const sendRequest = (endpoint, payload, mode = 'browser', opts = {}) => { 2 | const qs = new URLSearchParams( 3 | JSON.parse(JSON.stringify(payload)) 4 | ).toString() 5 | if (mode === 'browser') { 6 | navigator?.sendBeacon([endpoint, qs].join('?')) 7 | } else { 8 | const scheme = endpoint.split('://')[0] 9 | const req = require(`${scheme}`) 10 | const options = { 11 | headers: { 12 | 'User-Agent': opts.user_agent 13 | }, 14 | timeout: 500, 15 | } 16 | const request = req 17 | .get([endpoint, qs].join('?'), options, (resp) => { 18 | let data = '' 19 | resp.on('data', (chunk) => { 20 | data += chunk 21 | }) 22 | resp.on('end', () => { 23 | // TO-DO Handle Server Side Responses 24 | }) 25 | }) 26 | .on('error', (err) => { 27 | console.log('Error: ' + err.message) 28 | }) 29 | request.on('timeout', () => { 30 | request.destroy() 31 | }) 32 | } 33 | } 34 | --------------------------------------------------------------------------------