├── .github ├── FUNDING.yml └── workflows │ ├── create_tag.yml │ └── create_release.yml ├── babel.config.json ├── dist ├── tasks │ ├── logRequestsToConsoleTask.min.js │ ├── mapClientIdTask.min.js │ ├── mapPayloadSizeTask.min.js │ ├── snowPlowStreamingTask.min.js │ ├── privacySweepTask.min.js │ ├── eventBouncerTask.min.js │ ├── logRequestsToConsoleTask.js │ ├── sendToSecondaryMeasurementIdTask.min.js │ ├── privacySweepTask.js │ ├── mapPayloadSizeTask.js │ ├── preventDuplicateTransactionsTask.min.js │ ├── piiScrubberTask.min.js │ ├── mapClientIdTask.js │ ├── snowPlowStreamingTask.js │ ├── eventBouncerTask.js │ ├── sendToSecondaryMeasurementIdTask.js │ ├── preventDuplicateTransactionsTask.js │ └── piiScrubberTask.js ├── GA4CustomTask.min.js └── GA4CustomTask.js ├── .gitignore ├── src ├── helpers │ ├── isGA4Hit.ts │ └── storageHelper.ts ├── types.d.ts └── index.ts ├── tsconfig.json ├── tasks ├── logRequestsToConsoleTask │ ├── index.ts │ └── README.md ├── snowPlowStreamingTask │ ├── README.md │ └── index.ts ├── mapClientIdTask │ ├── README.md │ └── index.ts ├── mapPayloadSizeTask │ ├── README.md │ └── index.ts ├── privacySweepTask │ ├── index.ts │ └── README.md ├── sendToSecondaryMeasurementIdTask │ ├── README.md │ └── index.ts ├── preventDuplicateTransactionsTask │ ├── README.md │ └── index.ts ├── eventBouncerTask │ ├── README.md │ └── index.ts └── piiScrubberTask │ ├── README.md │ └── index.ts ├── eslint.config.js ├── package.json ├── rollup.config.js ├── README.md └── LICENSE /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: thyngster 2 | ko-fi: thyngster -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-transform-runtime" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /dist/tasks/logRequestsToConsoleTask.min.js: -------------------------------------------------------------------------------- 1 | var logRequestsToConsoleTask=function(){"use strict";return function(e){return console.group("%cReturnOfTheCustomTask: New Request (".concat(e.events.length," Events)"),"color: purple; font-size: large; font-weight: bold;"),console.log(e),console.groupEnd(),e}}(); 2 | -------------------------------------------------------------------------------- /dist/tasks/mapClientIdTask.min.js: -------------------------------------------------------------------------------- 1 | var mapClientIdTask=function(){"use strict";return function(e,a,n){if(void 0===n&&(n="event"),!(e&&a&&e.sharedPayload&&e.sharedPayload.cid))return console.error("mapClientIdTask: Invalid input. Request, name, and client ID are required."),e;var r="ep.".concat(a);return"user"===n&&e.events.length>0?e.events[0]["up.".concat(a)]=e.sharedPayload.cid:e.events.forEach((function(a){a[r]=e.sharedPayload.cid})),e}}(); 2 | -------------------------------------------------------------------------------- /dist/tasks/mapPayloadSizeTask.min.js: -------------------------------------------------------------------------------- 1 | var mapPayloadSizeTask=function(){"use strict";return function(e,n,r){if(!e||!n)return console.error("mapPayloadSizeTask: Request and name are required."),e;var a="epn.".concat(n),t=e.events.reduce((function(e,n){return e+new URLSearchParams(n).toString().length}),new URLSearchParams(e.sharedPayload||"").toString().length);return e.events.forEach((function(e){r&&r.length>0?r.includes(e.en)&&(e[a]=t):e[a]=t})),e}}(); 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist-ssr 12 | *.local 13 | 14 | # Editor directories and files 15 | .vscode/* 16 | !.vscode/extensions.json 17 | .idea 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | bun.lockb 25 | pages/* 26 | demo/* 27 | .github/workflows/npm-publish.yml 28 | -------------------------------------------------------------------------------- /dist/tasks/snowPlowStreamingTask.min.js: -------------------------------------------------------------------------------- 1 | var snowPlowStreamingTask=function(){"use strict";return function(e,n){if(!e||!n)return console.error("snowPlowStreamingTask: Request and endpointHistname are required."),e;var t=("/"!==n.slice(-1)?n+"/":n)+"com.google.analytics/v1";return e.events.forEach((function(n){var r=new XMLHttpRequest;r.open("POST",t,!0),r.setRequestHeader("Content-type","text/plain; charset=UTF-8");var a=Object.assign({},e.sharedPayload,n);r.send(new URLSearchParams(a).toString())})),e}}(); 2 | -------------------------------------------------------------------------------- /dist/tasks/privacySweepTask.min.js: -------------------------------------------------------------------------------- 1 | var privacySweepTask=function(){"use strict";return function(r){if(!r)return console.error("privacySweepTask: Request is required."),r;var e=["ecid","ur","are","frm","pscdl","tfd","tag_exp","dma","dma_cps","gcd","gcs","gsu","gcut","gcid","gclsrc","gclid","gaz","us_privacy","gdpr","gdpr_consent","us_privacy","_geo","_rdi","_uie","_uc"];return r.sharedPayload=Object.keys(r.sharedPayload).filter((function(r){return!e.includes(r)})).reduce((function(e,c){return e[c]=r.sharedPayload[c],e}),{}),r}}(); 2 | -------------------------------------------------------------------------------- /src/helpers/isGA4Hit.ts: -------------------------------------------------------------------------------- 1 | // Check if the URL belongs to GA4 2 | function isGA4Hit(url: string): boolean { 3 | try { 4 | const urlObj = new URL(url); 5 | const params = new URLSearchParams(urlObj.search); 6 | 7 | const tid = params.get('tid'); 8 | const cid = params.get('cid'); 9 | const v = params.get('v'); 10 | 11 | return !!tid && tid.startsWith('G-') && !!cid && v === '2'; 12 | } catch (e) { 13 | console.error('Error parsing URL:', e); 14 | return false; 15 | } 16 | } 17 | 18 | export default isGA4Hit; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", // Use 'esnext' for ES Modules 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "outDir": "./dist", 10 | "rootDir": "./", // Ensure this is the root directory of your source files 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true 13 | }, 14 | "include": ["src/**/*.ts", "tasks/**/*.ts"], 15 | "exclude": ["node_modules", "src/helpers"] 16 | } 17 | -------------------------------------------------------------------------------- /dist/tasks/eventBouncerTask.min.js: -------------------------------------------------------------------------------- 1 | var eventBouncerTask=function(){"use strict";return function(e,n){if(void 0===n&&(n={events:{}}),!e||!n||0===Object.keys(n).length)return console.error("eventBouncerTask: Request and allowedEventsSchema are required."),e;var t=n.sharedEventParameters||[];return e.events=e.events.filter((function(e){return Object.keys(n.events).includes(e.en)})).map((function(e){var r,s=t.concat((null===(r=n.events[e.en])||void 0===r?void 0:r.wlep)||[]),c={en:e.en};return Object.keys(e).forEach((function(n){if(n.startsWith("ep.")){var t=n.slice(3);s.includes(t)&&(c[n]=e[n])}else c[n]=e[n]})),c})),e}}(); 2 | -------------------------------------------------------------------------------- /tasks/logRequestsToConsoleTask/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Prints the current RequestModel to the console. 3 | * 4 | * @param request - The RequestModel object to be logged. 5 | * @returns The unchanged RequestModel object. 6 | */ 7 | const logRequestsToConsoleTask = (request: RequestModel): RequestModel => { 8 | console.group( 9 | `%cReturnOfTheCustomTask: New Request (${request.events.length} Events)`, 10 | 'color: purple; font-size: large; font-weight: bold;' 11 | ); 12 | console.log(request); 13 | console.groupEnd(); 14 | 15 | // Return the RequestModel object as is 16 | return request; 17 | }; 18 | 19 | export default logRequestsToConsoleTask; 20 | -------------------------------------------------------------------------------- /tasks/logRequestsToConsoleTask/README.md: -------------------------------------------------------------------------------- 1 | ![ReturnoftheCustomTask](https://github.com/user-attachments/assets/92f0b278-1d0e-4d62-a289-2ac203eefc25) 2 | 3 | # LogRequestToConsole Task 4 | 5 | This task will print the current request state to the console. Useful for debugging pourposes. 6 | 7 | # Parameters 8 | This task doesn't accept any parameters. 9 | 10 | # Usage 11 | 12 | > You can grab the code for this task from dist/tasks/ folder 13 | 14 | ```var logRequestsToConsoleTask = () => {...}``` 15 | 16 | 17 | Then we pass it back as a task. 18 | 19 | ``` 20 | var GA4CustomTaskInstance = new GA4CustomTask({ 21 | allowedMeasurementIds: ["G-DEBUGEMALL"], 22 | tasks: [ 23 | logRequestsToConsoleTask 24 | ] 25 | }); 26 | ``` 27 | -------------------------------------------------------------------------------- /dist/tasks/logRequestsToConsoleTask.js: -------------------------------------------------------------------------------- 1 | var logRequestsToConsoleTask = (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * Prints the current RequestModel to the console. 6 | * 7 | * @param request - The RequestModel object to be logged. 8 | * @returns The unchanged RequestModel object. 9 | */ 10 | var logRequestsToConsoleTask = function (request) { 11 | console.group("%cReturnOfTheCustomTask: New Request (".concat(request.events.length, " Events)"), 'color: purple; font-size: large; font-weight: bold;'); 12 | console.log(request); 13 | console.groupEnd(); 14 | // Return the RequestModel object as is 15 | return request; 16 | }; 17 | 18 | return logRequestsToConsoleTask; 19 | 20 | })(); 21 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import pluginJs from '@eslint/js'; 3 | import tseslint from 'typescript-eslint'; 4 | 5 | 6 | export default [ 7 | { 8 | ignores: ['**/dist/*'], 9 | }, 10 | { 11 | // Ignore the dist folder 12 | files: ['**/*.{ts, js}'] 13 | }, 14 | { 15 | 'rules': { 16 | 'quotes': ['error', 'single'], 17 | // we want to force semicolons 18 | 'semi': ['error', 'always'], 19 | // we use 2 spaces to indent our code 20 | 'indent': ['error', 2], 21 | // we want to avoid extraneous spaces 22 | 'no-multi-spaces': ['error'] 23 | } 24 | }, 25 | { 26 | languageOptions: { globals: globals.browser } 27 | }, 28 | pluginJs.configs.recommended, 29 | ...tseslint.configs.recommended, 30 | ]; -------------------------------------------------------------------------------- /tasks/snowPlowStreamingTask/README.md: -------------------------------------------------------------------------------- 1 | 2 | ![ReturnoftheCustomTask](https://github.com/user-attachments/assets/92f0b278-1d0e-4d62-a289-2ac203eefc25) 3 | 4 | # snowPlowStreaming Task 5 | 6 | This task will loop through all your request to find any PII 7 | 8 | # Usage 9 | ## Task Code 10 | 11 | ```var snowPlowStreaming = (...) => {...}``` 12 | > You can grab the code for this task from dist/tasks/ folder 13 | 14 | ## Code Example 15 | ``` 16 | var GA4CustomTaskInstance = new GA4CustomTask({ 17 | allowedMeasurementIds: ["G-DEBUGEMALL"], 18 | tasks: [ 19 | (requestModel) => snowPlowStreaming(requestModel, endpointHostname), 20 | ] 21 | }); 22 | ``` 23 | 24 | 25 | ### Parameters 26 | 27 | ```snowPlowStreaming(requestModel, '{{endpointHostname}}')``` 28 | |Parameter|Type|Description| 29 | |--|--|--| 30 | |endpointHostname|string|myendpoint.dot.com| -------------------------------------------------------------------------------- /tasks/mapClientIdTask/README.md: -------------------------------------------------------------------------------- 1 | 2 | ![ReturnoftheCustomTask](https://github.com/user-attachments/assets/92f0b278-1d0e-4d62-a289-2ac203eefc25) 3 | 4 | # mapClientId Task 5 | 6 | This task will read the current clientId ( ```&cid``` ) value from the request and map it back as an ```event parameter``` to all events in the payload. 7 | In the other side if we set the scope as ```'user'``` it will ba attached just to the first event on the request as an ```user property```. 8 | 9 | # Usage 10 | ## Task Code 11 | 12 | ```var mapClientIdTask = (...) => {...}``` 13 | > You can grab the code for this task from dist/tasks/ folder 14 | 15 | ## Code Example 16 | ``` 17 | var GA4CustomTaskInstance = new GA4CustomTask({ 18 | allowedMeasurementIds: ["G-DEBUGEMALL"], 19 | tasks: [ 20 | (requestModel) => mapClientIdTask(requestModel, 'client_id', 'event'), 21 | ] 22 | }); 23 | ``` 24 | 25 | 26 | ### Parameters 27 | 28 | ```mapClientIdTask(requestModel, '{{NAME}}', '{{SCOPE}}')``` 29 | |Parameter|Type|Description| 30 | |--|--|--| 31 | |name|string|It's the event-property or user parameter key that will be used| 32 | |scope|string|Event or User. 'event' will be used by default| 33 | -------------------------------------------------------------------------------- /.github/workflows/create_tag.yml: -------------------------------------------------------------------------------- 1 | name: Create Tag 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | create_tag: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | 15 | - name: Set up Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '20' 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: Build project 24 | run: npm run build 25 | 26 | - name: Create and push version tag 27 | id: tag 28 | run: | 29 | version=$(node -p "require('./package.json').version") 30 | echo "VERSION=$version" >> $GITHUB_ENV 31 | # Check if the tag already exists 32 | if git ls-remote --tags origin | grep -q "refs/tags/v$version"; then 33 | echo "Tag v$version already exists. Skipping tag creation." 34 | else 35 | # Create and push new tag 36 | git config user.name "GitHub Action" 37 | git config user.email "action@github.com" 38 | git tag v$version 39 | git push origin v$version 40 | fi 41 | -------------------------------------------------------------------------------- /tasks/mapPayloadSizeTask/README.md: -------------------------------------------------------------------------------- 1 | 2 | ![ReturnoftheCustomTask](https://github.com/user-attachments/assets/92f0b278-1d0e-4d62-a289-2ac203eefc25) 3 | 4 | # mapPayloadSize Task 5 | 6 | This task calculates the current payload size ( ```queryString``` + ```body post``` ) and maps it back to the event. 7 | You may want to attach this value only to the an specific event names, you can pass the events list array at the end (for example for only reporting this value for ecommerce events ) 8 | 9 | # Usage 10 | ## Task Code 11 | 12 | ```var mapClienmapPayloadSizetIdTask = (...) => {...}``` 13 | > You can grab the code for this task from dist/tasks/ folder 14 | 15 | ## Code Example 16 | ``` 17 | var GA4CustomTaskInstance = new GA4CustomTask({ 18 | allowedMeasurementIds: ["G-DEBUGEMALL"], 19 | tasks: [ 20 | (requestModel) => mapPayloadSize(requestModel, 'payload_size', ['add_to_cart','purchase']), 21 | ] 22 | }); 23 | ``` 24 | 25 | 26 | ### Parameters 27 | 28 | ```mapPayloadSize(requestModel, '{{NAME}}' ,[{{EVENTS_LIST}}])``` 29 | |Parameter|Type|Description| 30 | |--|--|--| 31 | |name|string|The event parameter name to be used| 32 | |eventsList|Array[string]|Takes an array of events that will get the size attached to| 33 | -------------------------------------------------------------------------------- /dist/tasks/sendToSecondaryMeasurementIdTask.min.js: -------------------------------------------------------------------------------- 1 | var sendToSecondaryMeasurementIdTask=function(){"use strict";return function(e,n,r,t){if(void 0===r&&(r=[]),void 0===t&&(t=[]),!e||!n||!Array.isArray(n)||0===n.length)return console.error("sendToSecondaryMeasurementIdTask: Request and Measurement IDs are required."),e;var o=JSON.parse(JSON.stringify(e));return o.events=function(e,n,r){var t=function(e){return Array.isArray(e)&&e.length>0},o=function(e,n,r){return n?e.filter((function(e){return r?n.has(e.en):!n.has(e.en)})):e};return t(n)?o(e,new Set(n),!0):t(r)?o(e,new Set(r),!1):e}(o.events,r,t),o.events.length>0&&n.forEach((function(e){var n,r,t,a,s,i,c;if(/^(G-|MC-)[A-Z0-9]+$/.test(e)){o.sharedPayload.tid=e;var u=(t=(r=o).endpoint,a=r.sharedPayload,s=r.events,i=new URLSearchParams(a).toString(),c=s.map((function(e){return new URLSearchParams(e).toString()})),{resource:1===s.length?"".concat(t,"?").concat(i,"&").concat(c[0]):"".concat(t,"?").concat(i),options:{method:"POST",body:s.length>1?c.join("\r\n"):null}});(null===(n=window.GA4CustomTask)||void 0===n?void 0:n.originalFetch)?window.GA4CustomTask.originalFetch(u.resource,u.options):console.error("GA4CustomTask.originalFetch is not defined.")}else console.error("Invalid Measurement ID format, skipping:",e)})),e}}(); 2 | -------------------------------------------------------------------------------- /tasks/privacySweepTask/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes all parameters that are not privacy friendly or that are not reported on Google Analytics 4 in any way. 3 | * 4 | * @param request - The request model to be modified. 5 | * @returns The modified request model. 6 | */ 7 | const privacySweepTask = (request: RequestModel): RequestModel => { 8 | if (!request) { 9 | console.error('privacySweepTask: Request is required.'); 10 | return request; // Returning the request even though it's not expected to be null or undefined 11 | } 12 | 13 | const blacklistedParams: string[] = [ 14 | 'ecid', 'ur', 'are', 'frm', 'pscdl', 'tfd', 'tag_exp', 'dma', 15 | 'dma_cps', 'gcd', 'gcs', 'gsu', 'gcut', 'gcid', 'gclsrc', 16 | 'gclid', 'gaz', 'us_privacy', 'gdpr', 'gdpr_consent', 17 | 'us_privacy', '_geo', '_rdi', '_uie', '_uc' 18 | ]; 19 | 20 | // Filtering and reducing the sharedPayload 21 | request.sharedPayload = Object.keys(request.sharedPayload) 22 | .filter(key => !blacklistedParams.includes(key)) 23 | .reduce<{ [key: string]: any }>((acc, key) => { 24 | acc[key] = request.sharedPayload[key]; 25 | return acc; 26 | }, {}); 27 | 28 | return request; 29 | }; 30 | 31 | export default privacySweepTask; 32 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | // types.d.ts 2 | 3 | interface Window { 4 | GA4CustomTask: any; // Replace 'any' with a more specific type if needed 5 | } 6 | 7 | interface GA4Event { 8 | en: string; // The string value 'en' can be up to 40 characters long 9 | [key: string]: any; // Additional dynamic properties with any key and value 10 | } 11 | 12 | type FetchArgs = [RequestInfo | URL, RequestInit?]; 13 | 14 | interface Interceptor { 15 | request?: (...args: FetchArgs) => FetchArgs | Promise; 16 | requestError?: (error: any) => Promise; 17 | response?: (response: Response) => Response | Promise; 18 | responseError?: (error: any) => Promise; 19 | } 20 | 21 | interface GA4CustomTaskSettings { 22 | allowedMeasurementIds?: string[]; 23 | tasks?: ((RequestModel: any) => any)[]; 24 | } 25 | 26 | interface RequestModel { 27 | endpoint: string; 28 | sharedPayload: { [key: string]: any }; // No need for null in the type 29 | events: { [key: string]: any }[]; 30 | __skip?: boolean; 31 | } 32 | 33 | interface ScrubPattern { 34 | id: string; 35 | regex: RegExp; 36 | replacement: string; 37 | } 38 | 39 | interface AllowedEventsSchema { 40 | sharedEventParameters?: string[]; 41 | events: { 42 | [key: string]: { 43 | wlep: string[]; 44 | }; 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /tasks/sendToSecondaryMeasurementIdTask/README.md: -------------------------------------------------------------------------------- 1 | ![ReturnoftheCustomTask](https://github.com/user-attachments/assets/92f0b278-1d0e-4d62-a289-2ac203eefc25) 2 | 3 | # sendToSecondaryMeasurementId Task 4 | 5 | This task will send a duplicate hit to the defined alternative Measurement Ids 6 | 7 | # Usage 8 | 9 | ## Task Code 10 | 11 | ```var sendToSecondaryMeasurementIdTask = (...) => {...}``` 12 | > You can grab the code for this task from dist/tasks/ folder 13 | 14 | ## Code Example 15 | 16 | ``` 17 | var GA4CustomTaskInstance = new GA4CustomTask({ 18 | allowedMeasurementIds: ["G-DEBUGEMALL"], 19 | tasks: [ 20 | (requestModel) => sendToSecondaryMeasurementIdTask(requestModel, ["G-SECONDID"], [], []), 21 | ] 22 | }); 23 | ``` 24 | 25 | ### Parameters 26 | 27 | ```sendToSecondaryMeasurementIdTask(requestModel, toMeasurementIds)``` 28 | |Parameter|Type|Description| 29 | |--|--|--| 30 | |toMeasurementIds|string[]|The array of measurement that will be getting a copy| 31 | |whiteListedEvents|string[]|List of event names that will be replicated to the accounts, not defining this value or it being an empty array will mean all will be send| 32 | |blackListedEvents|string[]|In case ```whiteListedEvents``` is not defined, or holding an empty array, will these events to be removed from payloads before sending a copy| 33 | 34 | -------------------------------------------------------------------------------- /tasks/mapPayloadSizeTask/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adds a event parameter on the provided event property name with the size of the payload. 3 | * 4 | * @param request - The request model to be modified. 5 | * @param name - The name to be used as part of the new key in the payload. 6 | * @param eventsList - The list of events where the size will be attached to 7 | * @returns The modified payload object. 8 | */ 9 | const mapPayloadSizeTask = ( 10 | request: RequestModel, 11 | name: string, 12 | eventsList: Array, 13 | ): RequestModel => { 14 | 15 | if (!request || !name) { 16 | console.error('mapPayloadSizeTask: Request and name are required.'); 17 | return request; 18 | } 19 | 20 | const key = `epn.${name}`; 21 | const total_payload_size = request.events.reduce((total_evt_size, event_payload) => { 22 | return total_evt_size + new URLSearchParams(event_payload).toString().length; 23 | }, new URLSearchParams(request.sharedPayload || '').toString().length); 24 | 25 | 26 | request.events.forEach((event) => { 27 | if (eventsList && eventsList.length > 0) { 28 | if (eventsList.includes(event['en'])) { 29 | event[key] = total_payload_size; 30 | } 31 | } else { 32 | event[key] = total_payload_size; 33 | } 34 | }); 35 | 36 | return request; 37 | 38 | }; 39 | 40 | export default mapPayloadSizeTask; -------------------------------------------------------------------------------- /dist/tasks/privacySweepTask.js: -------------------------------------------------------------------------------- 1 | var privacySweepTask = (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * Removes all parameters that are not privacy friendly or that are not reported on Google Analytics 4 in any way. 6 | * 7 | * @param request - The request model to be modified. 8 | * @returns The modified request model. 9 | */ 10 | var privacySweepTask = function (request) { 11 | if (!request) { 12 | console.error('privacySweepTask: Request is required.'); 13 | return request; // Returning the request even though it's not expected to be null or undefined 14 | } 15 | var blacklistedParams = [ 16 | 'ecid', 'ur', 'are', 'frm', 'pscdl', 'tfd', 'tag_exp', 'dma', 17 | 'dma_cps', 'gcd', 'gcs', 'gsu', 'gcut', 'gcid', 'gclsrc', 18 | 'gclid', 'gaz', 'us_privacy', 'gdpr', 'gdpr_consent', 19 | 'us_privacy', '_geo', '_rdi', '_uie', '_uc' 20 | ]; 21 | // Filtering and reducing the sharedPayload 22 | request.sharedPayload = Object.keys(request.sharedPayload) 23 | .filter(function (key) { return !blacklistedParams.includes(key); }) 24 | .reduce(function (acc, key) { 25 | acc[key] = request.sharedPayload[key]; 26 | return acc; 27 | }, {}); 28 | return request; 29 | }; 30 | 31 | return privacySweepTask; 32 | 33 | })(); 34 | -------------------------------------------------------------------------------- /tasks/mapClientIdTask/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adds a client ID to the payload object to the specified event/property name and scope. 3 | * 4 | * @param request - The request model to be modified. 5 | * @param name - The name to be used as part of the new key in the payload. 6 | * @param scope - The scope determines the prefix of the new key. Defaults to 'event'. 7 | * @returns The modified payload object. 8 | */ 9 | const mapClientIdTask = ( 10 | request: RequestModel, 11 | name: string, 12 | scope: 'event' | 'user' = 'event' 13 | ): RequestModel => { 14 | // Validate input parameters 15 | if (!request || !name || !request.sharedPayload || !request.sharedPayload.cid) { 16 | console.error('mapClientIdTask: Invalid input. Request, name, and client ID are required.'); 17 | return request; 18 | } 19 | 20 | const clientIdKey = `ep.${name}`; 21 | 22 | // Process based on the scope 23 | if (scope === 'user' && request.events.length > 0) { 24 | // Add the client ID to the first event if scope is 'user' 25 | request.events[0][`up.${name}`] = request.sharedPayload!.cid; 26 | } else { 27 | // Add the client ID to all events if scope is 'event' 28 | request.events.forEach(event => { 29 | event[clientIdKey] = request.sharedPayload!.cid; 30 | }); 31 | } 32 | 33 | // Return the modified request object 34 | return request; 35 | }; 36 | 37 | export default mapClientIdTask; 38 | -------------------------------------------------------------------------------- /tasks/privacySweepTask/README.md: -------------------------------------------------------------------------------- 1 | ![ReturnoftheCustomTask](https://github.com/user-attachments/assets/92f0b278-1d0e-4d62-a289-2ac203eefc25) 2 | 3 | # privacySweep Task 4 | 5 | This task removes any parameters that aren't directly related to Analytics, such as IDs other than the clientId or any other values that aren't relevant to Google Analytics 4. 6 | 7 | ## Current cleaned up parameters 8 | 9 | | Key | 10 | |----------------| 11 | | ecid | 12 | | ur | 13 | | are | 14 | | frm | 15 | | pscdl | 16 | | tfd | 17 | | tag_exp | 18 | | dma | 19 | | dma_cps | 20 | | gcd | 21 | | gcs | 22 | | gsu | 23 | | gcut | 24 | | gcid | 25 | | gclsrc | 26 | | gclid | 27 | | gaz | 28 | | us_privacy | 29 | | gdpr | 30 | | gdpr_consent | 31 | | us_privacy | 32 | | _geo | 33 | | _rdi | 34 | | _uie | 35 | | _uc | 36 | 37 | # Usage 38 | 39 | ## Task Code 40 | 41 | ```var privacySweepTask = (...) => {...}``` 42 | > You can grab the code for this task from dist/tasks/ folder 43 | 44 | ## Code Example 45 | 46 | ``` 47 | var GA4CustomTaskInstance = new GA4CustomTask({ 48 | allowedMeasurementIds: ["G-DEBUGEMALL"], 49 | tasks: [ 50 | privacySweepTask 51 | ] 52 | }); 53 | ``` 54 | 55 | ### Parameters 56 | 57 | No parameters accepted 58 | -------------------------------------------------------------------------------- /dist/tasks/mapPayloadSizeTask.js: -------------------------------------------------------------------------------- 1 | var mapPayloadSizeTask = (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * Adds a event parameter on the provided event property name with the size of the payload. 6 | * 7 | * @param request - The request model to be modified. 8 | * @param name - The name to be used as part of the new key in the payload. 9 | * @param eventsList - The list of events where the size will be attached to 10 | * @returns The modified payload object. 11 | */ 12 | var mapPayloadSizeTask = function (request, name, eventsList) { 13 | if (!request || !name) { 14 | console.error('mapPayloadSizeTask: Request and name are required.'); 15 | return request; 16 | } 17 | var key = "epn.".concat(name); 18 | var total_payload_size = request.events.reduce(function (total_evt_size, event_payload) { 19 | return total_evt_size + new URLSearchParams(event_payload).toString().length; 20 | }, new URLSearchParams(request.sharedPayload || '').toString().length); 21 | request.events.forEach(function (event) { 22 | if (eventsList && eventsList.length > 0) { 23 | if (eventsList.includes(event['en'])) { 24 | event[key] = total_payload_size; 25 | } 26 | } 27 | else { 28 | event[key] = total_payload_size; 29 | } 30 | }); 31 | return request; 32 | }; 33 | 34 | return mapPayloadSizeTask; 35 | 36 | })(); 37 | -------------------------------------------------------------------------------- /.github/workflows/create_release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | workflow_dispatch: # Allows manual trigger 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v3 12 | with: 13 | ref: main # Checkout the latest code from 'main' branch 14 | 15 | - name: Set up Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '20' 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: Build project 24 | run: npm run build 25 | 26 | - name: Get version from package.json 27 | id: version 28 | run: | 29 | VERSION=$(node -p "require('./package.json').version") 30 | echo "VERSION=$VERSION" >> $GITHUB_ENV 31 | 32 | - name: Create and push tag if not exists 33 | id: tag 34 | run: | 35 | if git rev-parse "v$VERSION" >/dev/null 2>&1; then 36 | echo "Tag v$VERSION already exists" 37 | else 38 | echo "Tag v$VERSION doesn't exist, creating..." 39 | git tag "v$VERSION" 40 | git push origin "v$VERSION" 41 | fi 42 | 43 | - name: Create GitHub release 44 | uses: softprops/action-gh-release@v1 45 | with: 46 | tag_name: "v${{ env.VERSION }}" # Use the version tag 47 | files: 'dist/**' # Specify the files to attach in the release 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | -------------------------------------------------------------------------------- /tasks/snowPlowStreamingTask/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sends a copy of the payload to a Snowplow endpoint. 3 | * 4 | * @param request - The request model to be modified. 5 | * @param endpointHostname - SnowPlow Collector Endpoint Hostname. 6 | * @returns The modified payload object. 7 | * 8 | * Based on https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/google-analytics-plugin/ 9 | */ 10 | const snowPlowStreamingTask = ( 11 | request: RequestModel, 12 | endpointHostname: string 13 | ): RequestModel => { 14 | if (!request || !endpointHostname) { 15 | console.error('snowPlowStreamingTask: Request and endpointHistname are required.'); 16 | return request; 17 | } 18 | 19 | const vendor = 'com.google.analytics'; 20 | const version = 'v1'; 21 | const fullEndpointUrl = ((endpointHostname.slice(-1) !== '/') ? endpointHostname + '/' : endpointHostname) + vendor + '/' + version; 22 | 23 | // Snowplow won't be handeling the requests will multiple events, so we will convert them to single requests 24 | request.events.forEach((event) => { 25 | const XMLRequest = new XMLHttpRequest(); 26 | XMLRequest.open('POST', fullEndpointUrl, true); 27 | XMLRequest.setRequestHeader('Content-type', 'text/plain; charset=UTF-8'); 28 | const payload = Object.assign({}, request.sharedPayload, event); 29 | XMLRequest.send(new URLSearchParams(payload).toString()); 30 | }); 31 | 32 | // Send the payload to the endpoint 33 | return request; 34 | }; 35 | 36 | export default snowPlowStreamingTask; -------------------------------------------------------------------------------- /dist/tasks/preventDuplicateTransactionsTask.min.js: -------------------------------------------------------------------------------- 1 | var preventDuplicateTransactionsTask=function(){"use strict";var t={sync:function(t){var e=document.cookie.split("; ").find((function(e){return e.startsWith(t+"=")})),n=localStorage.getItem(t),o=e?e.split("=")[1]:null;if(null!==o){var c=decodeURIComponent(o),a=parseInt(c.split(":")[0],10),r=n?parseInt(n.split(":")[0],10):0;a>r?localStorage.setItem(t,c):r>a&&null!==n&&(document.cookie="".concat(t,"=").concat(encodeURIComponent(n),"; path=/; SameSite=Strict; Secure"))}else null!==n&&(document.cookie="".concat(t,"=").concat(encodeURIComponent(n),"; path=/; SameSite=Strict; Secure"))},get:function(t){this.sync(t);var e=localStorage.getItem(t);return e&&e.split(":")[1]||null},set:function(t,e,n,o,c){void 0===n&&(n=7),void 0===o&&(o="/"),void 0===c&&(c="");var a=Date.now(),r="".concat(a,":").concat(e),i=encodeURIComponent(r);localStorage.setItem(t,r);var s=new Date(Date.now()+864e5*n).toUTCString();document.cookie="".concat(t,"=").concat(i,"; expires=").concat(s,"; path=").concat(o,"; domain=").concat(c,"; SameSite=Strict; Secure")}};return function(e,n){if(void 0===n&&(n="__ad_trans_dedup"),!e)return console.error("preventDuplicateTransactionsTask: Request is required."),e;if(e.events.some((function(t){return"purchase"===t.en}))){var o=[];try{var c=t.get(n);o=c?JSON.parse(c):[]}catch(t){console.error("Failed to parse stored transactions:",t)}e.events=e.events.filter((function(t){return("purchase"!==t.en||!o.includes(t["ep.transaction_id"]))&&(o.push(t["ep.transaction_id"]),!0)})),t.set(n,JSON.stringify(o))}return 0===e.events.length&&(e.__skip=!0),e}}(); 2 | -------------------------------------------------------------------------------- /tasks/preventDuplicateTransactionsTask/README.md: -------------------------------------------------------------------------------- 1 | 2 | ![ReturnoftheCustomTask](https://github.com/user-attachments/assets/92f0b278-1d0e-4d62-a289-2ac203eefc25) 3 | 4 | # preventDuplicateTransactions Task 5 | 6 | This task will take the current purchase event transaction_id and save it into the current browser storage. If there's a subsecuente 7 | purchase event with the same ```transaction_id``` the purchase event will be removed from the payload. 8 | 9 | This tasks relays on cookies and localStorage to keep track of the transactions. Also it keeps both storages synched, meaning that if 10 | the user clears it's cookies localStarage will be read and cookie will be recovered. 11 | 12 | Since a request may contain more than one purcahse event, instead of blocking the hit, individual purchase events are 13 | removed from the payload. 14 | 15 | # Usage 16 | ## Task Code 17 | 18 | ```var preventDuplicateTransactions = (...) => {...}``` 19 | > You can grab the code for this task from dist/tasks/ folder 20 | 21 | ## Code Example 22 | ``` 23 | var GA4CustomTaskInstance = new GA4CustomTask({ 24 | allowedMeasurementIds: ["G-DEBUGEMALL"], 25 | tasks: [ 26 | (requestModel) => preventDuplicateTransactions(requestModel, '__transaction_cookie'), 27 | ] 28 | }); 29 | ``` 30 | 31 | ### Parameters 32 | 33 | ```preventDuplicateTransactions(requestModel, '{{storeName}}')``` 34 | |Parameter|Type|Description| 35 | |--|--|--| 36 | |storeName|string|The cookie name / localStorage name to be used to keep track of transactions, ```__ad_trans_dedup``` is used by default| 37 | -------------------------------------------------------------------------------- /dist/tasks/piiScrubberTask.min.js: -------------------------------------------------------------------------------- 1 | var piiScrubberTask=function(){"use strict";return function(e,r,a,n,t){if(void 0===r&&(r=[]),void 0===a&&(a=[]),void 0===t&&(t=!1),!e)return console.error("piiScrubberTask: Request is required."),e;var o=["email","mail","name","surname"].concat(r).filter(Boolean),c=[{id:"email",regex:/[a-zA-Z0-9-_.]+@[a-zA-Z0-9-_.]+/,replacement:"[email_redacted]"}].concat(a).filter(Boolean),i={},s=[],l=function(e,r){null!==e?Object.entries(e).forEach((function(a){var n=a[0],t=a[1];if(c.forEach((function(a){var o=a.id,c=a.regex,l=a.replacement;"string"==typeof t&&c.test(t)&&(i[n]={origin:r,rule:"matched pattern: ".concat(o),original:t,scrubbed:t.replace(c,l)},e[n]=t.replace(c,l),s.push(n))})),"string"==typeof t&&function(e){return/^(?:[a-zA-Z][a-zA-Z\d+\-.]*:\/\/[^\s]*)(?:\?[^\s]*)?$/.test(e)}(t))try{var l=new URL(t),u=new URLSearchParams(l.search);o.forEach((function(e){u.has(e)&&(u.set(e,"parameter_removed"),i[n]={origin:r,rule:"blacklisted parameter",original:t,scrubbed:l.toString()},s.push(e))})),u.forEach((function(e,a){c.forEach((function(t){var o=t.id,c=t.regex,d=t.replacement;if(c.test(e)){var f=e.replace(c,d);u.set(a,f),i[n]={origin:r,rule:"matched pattern: ".concat(o),original:l.toString(),scrubbed:f},s.push(a)}}))})),l.search=u.toString(),e[n]=l.toString()}catch(e){console.error("Invalid URL:",t,e)}})):console.warn("Skipping scrubbing for ".concat(r," as data is null"))};return l(e.sharedPayload,"sharedPayload"),e.events.forEach((function(e){return l(e,"events")})),n&&s.length>0&&n(i),t&&Object.keys(i).length>0&&(console.groupCollapsed("PII Scrubber: Scrubbed Data"),console.log(i),console.groupEnd()),e}}(); 2 | -------------------------------------------------------------------------------- /dist/tasks/mapClientIdTask.js: -------------------------------------------------------------------------------- 1 | var mapClientIdTask = (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * Adds a client ID to the payload object to the specified event/property name and scope. 6 | * 7 | * @param request - The request model to be modified. 8 | * @param name - The name to be used as part of the new key in the payload. 9 | * @param scope - The scope determines the prefix of the new key. Defaults to 'event'. 10 | * @returns The modified payload object. 11 | */ 12 | var mapClientIdTask = function (request, name, scope) { 13 | if (scope === void 0) { scope = 'event'; } 14 | // Validate input parameters 15 | if (!request || !name || !request.sharedPayload || !request.sharedPayload.cid) { 16 | console.error('mapClientIdTask: Invalid input. Request, name, and client ID are required.'); 17 | return request; 18 | } 19 | var clientIdKey = "ep.".concat(name); 20 | // Process based on the scope 21 | if (scope === 'user' && request.events.length > 0) { 22 | // Add the client ID to the first event if scope is 'user' 23 | request.events[0]["up.".concat(name)] = request.sharedPayload.cid; 24 | } 25 | else { 26 | // Add the client ID to all events if scope is 'event' 27 | request.events.forEach(function (event) { 28 | event[clientIdKey] = request.sharedPayload.cid; 29 | }); 30 | } 31 | // Return the modified request object 32 | return request; 33 | }; 34 | 35 | return mapClientIdTask; 36 | 37 | })(); 38 | -------------------------------------------------------------------------------- /dist/tasks/snowPlowStreamingTask.js: -------------------------------------------------------------------------------- 1 | var snowPlowStreamingTask = (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * Sends a copy of the payload to a Snowplow endpoint. 6 | * 7 | * @param request - The request model to be modified. 8 | * @param endpointHostname - SnowPlow Collector Endpoint Hostname. 9 | * @returns The modified payload object. 10 | * 11 | * Based on https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/google-analytics-plugin/ 12 | */ 13 | var snowPlowStreamingTask = function (request, endpointHostname) { 14 | if (!request || !endpointHostname) { 15 | console.error('snowPlowStreamingTask: Request and endpointHistname are required.'); 16 | return request; 17 | } 18 | var vendor = 'com.google.analytics'; 19 | var version = 'v1'; 20 | var fullEndpointUrl = ((endpointHostname.slice(-1) !== '/') ? endpointHostname + '/' : endpointHostname) + vendor + '/' + version; 21 | // Snowplow won't be handeling the requests will multiple events, so we will convert them to single requests 22 | request.events.forEach(function (event) { 23 | var XMLRequest = new XMLHttpRequest(); 24 | XMLRequest.open('POST', fullEndpointUrl, true); 25 | XMLRequest.setRequestHeader('Content-type', 'text/plain; charset=UTF-8'); 26 | var payload = Object.assign({}, request.sharedPayload, event); 27 | XMLRequest.send(new URLSearchParams(payload).toString()); 28 | }); 29 | // Send the payload to the endpoint 30 | return request; 31 | }; 32 | 33 | return snowPlowStreamingTask; 34 | 35 | })(); 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "the-return-of-the-custom-task", 3 | "private": true, 4 | "author": "David Vallejo (https://www.thyngster.com/)", 5 | "version": "0.0.2", 6 | "license": "Apache-2.0", 7 | "type": "module", 8 | "scripts": { 9 | "build": "rollup -c", 10 | "watch": "rollup -c -w", 11 | "build-verbose": "rollup -c --verbose", 12 | "lint": "eslint . --fix" 13 | }, 14 | "devDependencies": { 15 | "@eslint/js": "^9.10.0", 16 | "@rollup/plugin-terser": "^0.4.4", 17 | "eslint": "^9.10.0", 18 | "globals": "^15.9.0", 19 | "rollup": "^4.21.3", 20 | "rollup-plugin-dts": "^6.1.1", 21 | "rollup-plugin-esbuild": "^6.1.1", 22 | "typescript-eslint": "^8.4.0" 23 | }, 24 | "dependencies": { 25 | "@babel/cli": "^7.25.6", 26 | "@babel/core": "^7.25.2", 27 | "@babel/plugin-transform-runtime": "^7.25.4", 28 | "@babel/preset-env": "^7.25.4", 29 | "@babel/preset-typescript": "^7.24.7", 30 | "@rollup/plugin-alias": "^5.1.0", 31 | "@rollup/plugin-babel": "^6.0.4", 32 | "@rollup/plugin-node-resolve": "^15.2.3", 33 | "@rollup/plugin-typescript": "^11.1.6", 34 | "@typescript-eslint/eslint-plugin": "^8.5.0", 35 | "@typescript-eslint/parser": "^8.5.0", 36 | "fetch-intercept": "^2.4.0", 37 | "fs": "^0.0.1-security", 38 | "rollup-plugin-delete": "^2.0.0", 39 | "rollup-plugin-terser": "^7.0.2", 40 | "rollup-plugin-visualizer": "^5.12.0", 41 | "tsc": "^2.0.4", 42 | "tslib": "^2.7.0", 43 | "typescript": "^5.6.2" 44 | }, 45 | "funding": [ 46 | { 47 | "type": "github", 48 | "url": "https://github.com/sponsors/thyngster" 49 | }, 50 | { 51 | "type": "individual", 52 | "url": "https://ko-fi.com/thyngster" 53 | } 54 | ] 55 | } -------------------------------------------------------------------------------- /tasks/eventBouncerTask/README.md: -------------------------------------------------------------------------------- 1 | ![ReturnoftheCustomTask](https://github.com/user-attachments/assets/92f0b278-1d0e-4d62-a289-2ac203eefc25) 2 | 3 | # eventBouncer Task 4 | 5 | This task acts like the bouncer of your GA4 party – no uninvited events get through the door! 🎉 It’s got eagle eyes for any sneaky, unexpected parameters trying to tag along with your events, 6 | and they’ll get turned away at the gate too. 🚫 No shady events here – only the VIP events make it in! 😎 7 | 8 | # Parameters 9 | 10 | This task takes a schema of the events definition: 11 | The ```sharedEventParameters``` Array will hold a list of the parameters that may be included on any event. ( This way we don't need to defined them on each of the events ) 12 | The ```events``` Object, holds the event guest list, any event coming through that is not on this list will be removed from the payload. We can defined also the ```wlep``` ( White Listed Event Parameters ) , which 13 | will check the enabled parameters for the current event. If the current event has a parameter that is not on the ```wlep``` or it's defined from the sharedEventParameters it will be removed from the event data. 14 | 15 | 16 | 17 | ```eventBouncerTask(requestModel, {{allowedEventsSchema}})``` 18 | 19 | |Parameter|Type|Description| 20 | |--|--|--| 21 | |allowedEventsSchema|object|| 22 | 23 | # allowedEvents Schema 24 | 25 | ``` 26 | { 27 | "sharedEventParameters": ["page_type"], 28 | "events": { 29 | "page_view": { 30 | "wlep": [] 31 | }, 32 | "add_to_cart": { 33 | "wlep": [] 34 | } 35 | } 36 | } 37 | ``` 38 | 39 | # Usage 40 | 41 | > You can grab the code for this task from dist/tasks/ folder 42 | 43 | ```var eventBouncerTask = () => {...}``` 44 | 45 | Then we pass it back as a task. 46 | 47 | ``` 48 | var GA4CustomTaskInstance = new GA4CustomTask({ 49 | allowedMeasurementIds: ["G-DEBUGEMALL"], 50 | tasks: [ 51 | (request) => eventBouncerTask(requestModel, {{allowedEventsSchema}}), 52 | ] 53 | }); 54 | ``` 55 | -------------------------------------------------------------------------------- /tasks/eventBouncerTask/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cleans up the events and parameters to match the defined model 3 | * 4 | * @param request - The RequestModel object to be cleaned. 5 | * @param allowedEventsSchema - The allowedEvents Schema object. 6 | * @returns The cleaned RequestModel object. 7 | */ 8 | const eventBouncerTask = ( 9 | request: RequestModel, 10 | allowedEventsSchema: AllowedEventsSchema = { events: {} }, 11 | ): RequestModel => { 12 | if (!request || !allowedEventsSchema || Object.keys(allowedEventsSchema).length === 0) { 13 | console.error('eventBouncerTask: Request and allowedEventsSchema are required.'); 14 | return request; 15 | } 16 | 17 | // Default empty array if sharedEventParameters is not provided 18 | const sharedEventParameters = allowedEventsSchema.sharedEventParameters || []; 19 | 20 | // Remove unwanted events and parameters 21 | request.events = request.events 22 | .filter(event => Object.keys(allowedEventsSchema.events).includes(event.en)) // Step 1: Filter events based on valid event names 23 | .map(event => { 24 | const eventAllowedParams = sharedEventParameters.concat( 25 | allowedEventsSchema.events[event.en]?.wlep || [] // Use empty array if wlep is not defined 26 | ); 27 | 28 | // Filter the parameters that start with "ep." based on the allowed parameters 29 | const filteredEvent: GA4Event = { en: event.en }; // Keep only valid parameters 30 | Object.keys(event).forEach(key => { 31 | if (key.startsWith('ep.')) { 32 | const paramName = key.slice(3); // Remove "ep." prefix 33 | if (eventAllowedParams.includes(paramName)) { 34 | filteredEvent[key] = event[key]; 35 | } 36 | } else { 37 | // Keep non-"ep." properties as is (like "en") 38 | filteredEvent[key] = event[key]; 39 | } 40 | }); 41 | 42 | return filteredEvent; 43 | }); 44 | 45 | // Return the cleaned RequestModel object 46 | return request; 47 | }; 48 | 49 | export default eventBouncerTask; 50 | -------------------------------------------------------------------------------- /tasks/preventDuplicateTransactionsTask/index.ts: -------------------------------------------------------------------------------- 1 | import storageHelper from '../../src/helpers/storageHelper'; 2 | 3 | /** 4 | * Monitors purchase events and keeps track of transaction IDs to prevent duplicate transactions. 5 | * Used a synced dual localStorage and cookie storage to store transaction IDs. 6 | * 7 | * @param request - The request model to be modified. 8 | * @param storeName - The storage type to be used. Defaults to 'cookie'. 9 | * @returns The modified payload object. 10 | */ 11 | 12 | const preventDuplicateTransactionsTask = ( 13 | request: RequestModel, 14 | storeName: string = '__ad_trans_dedup' 15 | ): RequestModel => { 16 | 17 | if (!request) { 18 | console.error('preventDuplicateTransactionsTask: Request is required.'); 19 | return request; 20 | } 21 | 22 | const hasPurchaseEvents = request.events.some(e => e.en === 'purchase'); 23 | 24 | if (hasPurchaseEvents) { 25 | let alreadyTrackedTransactions: string[] = []; 26 | 27 | try { 28 | const storedTransactions = storageHelper.get(storeName); 29 | alreadyTrackedTransactions = storedTransactions ? JSON.parse(storedTransactions) : []; 30 | } catch (error) { 31 | console.error('Failed to parse stored transactions:', error); 32 | } 33 | 34 | // Remove duplicate purchase events 35 | request.events = request.events.filter(event => { 36 | if (event.en === 'purchase' && alreadyTrackedTransactions.includes(event['ep.transaction_id'])) { 37 | return false; // Remove this event 38 | } 39 | alreadyTrackedTransactions.push(event['ep.transaction_id']); // Add to tracked transactions 40 | return true; // Keep this event 41 | }); 42 | 43 | // Write the new transactions to Storage 44 | storageHelper.set(storeName, JSON.stringify(alreadyTrackedTransactions)); 45 | } 46 | 47 | if(request.events.length === 0) { 48 | // So it seems that all events were removed, there's no need to even sent this request 49 | request.__skip = true; 50 | } 51 | return request; 52 | }; 53 | 54 | 55 | export default preventDuplicateTransactionsTask; -------------------------------------------------------------------------------- /dist/GA4CustomTask.min.js: -------------------------------------------------------------------------------- 1 | !function(e,r){"object"==typeof exports&&"undefined"!=typeof module?module.exports=r():"function"==typeof define&&define.amd?define(r):(e="undefined"!=typeof globalThis?globalThis:e||self).GA4CustomTask=r()}(this,(function(){"use strict";var e=[];var r=function(n){var t;if(n)return e.push({request:function(e,t){void 0===t&&(t={});try{if("string"==typeof e&&function(e){try{var r=new URL(e),n=new URLSearchParams(r.search),t=n.get("tid"),o=n.get("cid"),a=n.get("v");return!!t&&t.startsWith("G-")&&!!o&&"2"===a}catch(e){return console.error("Error parsing URL:",e),!1}}(e)){var o=new URL(e),a={endpoint:o.origin+o.pathname,sharedPayload:{},events:[]},i=Array.from(new URLSearchParams(o.search).entries());t.body?(a.sharedPayload=Object.fromEntries(i),a.events=t.body.split("\r\n").map((function(e){return Object.fromEntries(new URLSearchParams(e).entries())}))):(a.sharedPayload=Object.fromEntries(i.slice(0,i.findIndex((function(e){return"en"===e[0]})))),a.events=[Object.fromEntries(i.slice(i.findIndex((function(e){return"en"===e[0]}))))]);var c=Object.fromEntries(new URLSearchParams(o.search));if(n.allowedMeasurementIds&&Array.isArray(n.allowedMeasurementIds)&&!n.allowedMeasurementIds.includes(c.tid))return[e,t];Array.isArray(n.tasks)&&n.tasks.forEach((function(e){"function"==typeof e?a=e.call({originalFetch:r.originalFetch},a):console.warn("Callback is not a function:",e)}));var s=(u=a,f=new URLSearchParams(u.sharedPayload||{}).toString(),d=u.events.map((function(e){return new URLSearchParams(e).toString()})).join("\r\n"),{endpoint:u.endpoint,resource:f,body:d});t.body?(e="".concat(s.endpoint,"?").concat(s.resource),t.body=s.body):e="".concat(s.endpoint,"?").concat(s.resource,"&").concat(s.body)}}catch(e){console.error("Error in fetch interceptor:",e)}var u,f,d;return[e,t]},response:function(e){return e},responseError:function(e){return Promise.reject(e)}}),window.fetch=(t=window.fetch,function(r,n){return function(r,n){var t=e.reduce((function(e,r){return[r].concat(e)}),[]),o=Promise.resolve(n);t.forEach((function(e){var r=e.request,n=e.requestError;(r||n)&&(o=o.then((function(e){return r?r.apply(void 0,e):e}),n))}));var a=o.then((function(e){return r(e[0],e[1])}));return t.forEach((function(e){var r=e.response,n=e.responseError;(r||n)&&(a=a.then(r,n))})),a}(t,[r,n])}),{clear:function(){e=[]}}};return r.originalFetch=window.fetch,r})); 2 | -------------------------------------------------------------------------------- /src/helpers/storageHelper.ts: -------------------------------------------------------------------------------- 1 | interface StorageHelper { 2 | sync(name: string): void; 3 | get(name: string): string | null; 4 | set(name: string, value: string, days?: number, path?: string, domain?: string): void; 5 | } 6 | 7 | const storageHelper: StorageHelper = { 8 | sync(name: string): void { 9 | const cookie = document.cookie.split('; ').find(row => row.startsWith(name + '=')); 10 | const valueFromLocalStorage = localStorage.getItem(name); 11 | 12 | const valueFromCookie = cookie ? cookie.split('=')[1] : null; 13 | 14 | if (valueFromCookie !== null) { 15 | const decodedValue = decodeURIComponent(valueFromCookie); 16 | const timestampFromCookie = parseInt(decodedValue.split(':')[0], 10); 17 | const timestampFromLocalStorage = valueFromLocalStorage ? parseInt(valueFromLocalStorage.split(':')[0], 10) : 0; 18 | 19 | if (timestampFromCookie > timestampFromLocalStorage) { 20 | localStorage.setItem(name, decodedValue); 21 | } else if (timestampFromLocalStorage > timestampFromCookie) { 22 | if (valueFromLocalStorage !== null) { 23 | document.cookie = `${name}=${encodeURIComponent(valueFromLocalStorage)}; path=/; SameSite=Strict; Secure`; 24 | } 25 | } 26 | } else { 27 | // Handle case where cookie is null 28 | if (valueFromLocalStorage !== null) { 29 | document.cookie = `${name}=${encodeURIComponent(valueFromLocalStorage)}; path=/; SameSite=Strict; Secure`; 30 | } 31 | } 32 | }, 33 | 34 | get(name: string): string | null { 35 | this.sync(name); 36 | const value = localStorage.getItem(name); 37 | return value ? value.split(':')[1] || null : null; 38 | }, 39 | 40 | set(name: string, value: string, days: number = 7, path: string = '/', domain: string = ''): void { 41 | const timestamp = Date.now(); 42 | const valueWithTimestamp = `${timestamp}:${value}`; 43 | 44 | const encodedValue = encodeURIComponent(valueWithTimestamp); 45 | 46 | localStorage.setItem(name, valueWithTimestamp); 47 | 48 | const expires = new Date(Date.now() + days * 864e5).toUTCString(); 49 | document.cookie = `${name}=${encodedValue}; expires=${expires}; path=${path}; domain=${domain}; SameSite=Strict; Secure`; 50 | } 51 | }; 52 | 53 | export default storageHelper; 54 | -------------------------------------------------------------------------------- /dist/tasks/eventBouncerTask.js: -------------------------------------------------------------------------------- 1 | var eventBouncerTask = (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * Cleans up the events and parameters to match the defined model 6 | * 7 | * @param request - The RequestModel object to be cleaned. 8 | * @param allowedEventsSchema - The allowedEvents Schema object. 9 | * @returns The cleaned RequestModel object. 10 | */ 11 | var eventBouncerTask = function (request, allowedEventsSchema) { 12 | if (allowedEventsSchema === void 0) { allowedEventsSchema = { events: {} }; } 13 | if (!request || !allowedEventsSchema || Object.keys(allowedEventsSchema).length === 0) { 14 | console.error('eventBouncerTask: Request and allowedEventsSchema are required.'); 15 | return request; 16 | } 17 | // Default empty array if sharedEventParameters is not provided 18 | var sharedEventParameters = allowedEventsSchema.sharedEventParameters || []; 19 | // Remove unwanted events and parameters 20 | request.events = request.events 21 | .filter(function (event) { return Object.keys(allowedEventsSchema.events).includes(event.en); }) // Step 1: Filter events based on valid event names 22 | .map(function (event) { 23 | var _a; 24 | var eventAllowedParams = sharedEventParameters.concat(((_a = allowedEventsSchema.events[event.en]) === null || _a === void 0 ? void 0 : _a.wlep) || [] // Use empty array if wlep is not defined 25 | ); 26 | // Filter the parameters that start with "ep." based on the allowed parameters 27 | var filteredEvent = { en: event.en }; // Keep only valid parameters 28 | Object.keys(event).forEach(function (key) { 29 | if (key.startsWith('ep.')) { 30 | var paramName = key.slice(3); // Remove "ep." prefix 31 | if (eventAllowedParams.includes(paramName)) { 32 | filteredEvent[key] = event[key]; 33 | } 34 | } 35 | else { 36 | // Keep non-"ep." properties as is (like "en") 37 | filteredEvent[key] = event[key]; 38 | } 39 | }); 40 | return filteredEvent; 41 | }); 42 | // Return the cleaned RequestModel object 43 | return request; 44 | }; 45 | 46 | return eventBouncerTask; 47 | 48 | })(); 49 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import { babel } from '@rollup/plugin-babel'; 6 | import { terser } from 'rollup-plugin-terser'; 7 | 8 | function getEntryPoints() { 9 | const entries = {}; 10 | 11 | // Handle src directory 12 | const srcIndex = path.resolve('src/index.ts'); 13 | if (fs.existsSync(srcIndex)) { 14 | entries['GA4CustomTask'] = srcIndex; 15 | } 16 | 17 | // Handle tasks directory 18 | const tasksDir = path.resolve('tasks'); 19 | fs.readdirSync(tasksDir, { withFileTypes: true }).forEach((entry) => { 20 | if (entry.isDirectory()) { 21 | const subDir = path.join(tasksDir, entry.name); 22 | const indexFile = path.join(subDir, 'index.ts'); 23 | if (fs.existsSync(indexFile)) { 24 | entries[entry.name] = indexFile; 25 | } 26 | } 27 | }); 28 | 29 | return entries; 30 | } 31 | 32 | const entries = getEntryPoints(); 33 | const outputDir = path.resolve('dist'); 34 | 35 | const configs = Object.entries(entries).map(([name, input]) => { 36 | const isSrc = name === 'GA4CustomTask'; 37 | const outputPath = isSrc 38 | ? path.join(outputDir, `${name}.js`) 39 | : path.join(outputDir, 'tasks', `${name}.js`); 40 | 41 | return { 42 | input, 43 | output: [ 44 | { 45 | file: outputPath, 46 | format: isSrc ? 'umd' : 'iife', 47 | sourcemap: false, 48 | name: isSrc ? 'GA4CustomTask' : name, 49 | exports: 'default', 50 | }, 51 | { 52 | file: outputPath.replace(/\.js$/, '.min.js'), 53 | format: isSrc ? 'umd' : 'iife', 54 | sourcemap: false, 55 | name: isSrc ? 'GA4CustomTask' : name, 56 | exports: 'default', 57 | plugins: [ terser({ 58 | compress: { 59 | drop_console: false, 60 | }, 61 | format: { 62 | beautify: false, 63 | comments: false, 64 | } 65 | })], 66 | }, 67 | ], 68 | plugins: [ 69 | nodeResolve({ 70 | extensions: ['.js', '.ts'], 71 | }), 72 | typescript({ 73 | tsconfig: './tsconfig.json', 74 | sourceMap: false, 75 | }), 76 | babel({ 77 | babelHelpers: 'bundled', 78 | exclude: 'node_modules/**', 79 | presets: ['@babel/preset-env'], 80 | }), 81 | ], 82 | }; 83 | }); 84 | 85 | export default configs; 86 | -------------------------------------------------------------------------------- /tasks/piiScrubberTask/README.md: -------------------------------------------------------------------------------- 1 | 2 | ![ReturnoftheCustomTask](https://github.com/user-attachments/assets/92f0b278-1d0e-4d62-a289-2ac203eefc25) 3 | 4 | # piiScrubberTask Task 5 | 6 | This task will loop through all your request to find any PII 7 | 8 | # Usage 9 | ## Task Code 10 | 11 | ```var piiScrubberTask = (...) => {...}``` 12 | > You can grab the code for this task from dist/tasks/ folder 13 | 14 | ## Code Example 15 | ``` 16 | var GA4CustomTaskInstance = new GA4CustomTask({ 17 | allowedMeasurementIds: ["G-DEBUGEMALL"], 18 | tasks: [ 19 | (requestModel) => piiScrubberTask(requestModel, [], [], null, true), 20 | ] 21 | }); 22 | ``` 23 | 24 | ### Parameters 25 | 26 | ```piiScrubberTask(requestModel, '{{NAME}}', '{{SCOPE}}')``` 27 | |Parameter|Type|Description| 28 | |--|--|--| 29 | |queryParamsBlackList|[]string|List of parameters to be removed from URL Like values| 30 | |scrubPatternsList|[]object|List of parameters to be removed from URL Like values| 31 | |callback|Function|You can pass a function and the current scrubbing will be passed back, you can use this for logging if any PII was found, that way if there's any Privacy issue you can take action| 32 | |logScrubbing|boolean|Event or User. 'event' will be used by default| 33 | 34 | ## Passing Custom Scrubbing Patters. 35 | We can make the scrubber to search for some patterns over all the values in the payload, and replace the matches with the defined redact text. 36 | The scrubber by default cleans up all the emails like texts it finds. 37 | 38 | This is the current pattern defined: 39 | ``` 40 | { 41 | id: 'email', 42 | regex: /[a-zA-Z0-9-_.]+@[a-zA-Z0-9-_.]+/, 43 | replacement: '[email_redacted]' 44 | } 45 | ``` 46 | 47 | If we want to add some new patterns we need pass an array that will have a main key for the pattern ID, and the the regex and the replacement text. 48 | 49 | For example setting up out scrubber as in the following example will scrub any UK Postal Code and any SSN Like string on the payload: 50 | 51 | ``` 52 | var GA4CustomTaskInstance = new GA4CustomTask({ 53 | allowedMeasurementIds: ["G-DEBUGEMALL"], 54 | tasks: [ 55 | (requestModel) => piiScrubberTask(requestModel, [], 56 | [{ 57 | id: 'ukpc', 58 | regex: /[A-Za-z][A-Ha-hJ-Yj-y]?[0-9][A-Za-z0-9]? ?[0-9][A-Za-z]{2}|[Gg][Ii][Rr] ?0[Aa]{2}/, 59 | replacement: '[uk_postal_code_redacted]' 60 | }, 61 | { 62 | id: 'ssn', 63 | regex: /\b^(?!000|666)[0-8][0-9]{2}-(?!00)[0-9]{2}-(?!0000)[0-9]{4}\b/, 64 | replacement: '[ssn_redacted]' 65 | }] 66 | , null, true), 67 | ] 68 | }); 69 | ``` -------------------------------------------------------------------------------- /tasks/sendToSecondaryMeasurementIdTask/index.ts: -------------------------------------------------------------------------------- 1 | interface Event { 2 | en: string; // Event name or identifier 3 | [key: string]: any; // Additional properties 4 | } 5 | 6 | interface RequestModel { 7 | endpoint: string; 8 | sharedPayload: { [key: string]: any }; 9 | events: Event[]; 10 | __skip?: boolean; 11 | } 12 | 13 | /** 14 | * Sends a copy to secondary Measurement Ids 15 | * You can control which events to copy 16 | * 17 | * @param request - The request model to be modified. 18 | * @param toMeasurementIds - Array of Measurement IDs to send a copy to 19 | * @param whiteListedEvents - Array of event names that will be kept 20 | * @param blackListedEvents - Array of event names that will be removed 21 | * @returns The modified request model. 22 | */ 23 | const sendToSecondaryMeasurementIdTask = ( 24 | request: RequestModel, 25 | toMeasurementIds: string[], 26 | whiteListedEvents: string[] = [], 27 | blackListedEvents: string[] = [] 28 | ): RequestModel => { 29 | if (!request || !toMeasurementIds || !Array.isArray(toMeasurementIds) || toMeasurementIds.length === 0) { 30 | console.error('sendToSecondaryMeasurementIdTask: Request and Measurement IDs are required.'); 31 | return request; 32 | } 33 | 34 | const buildFetchRequest = (requestModel: RequestModel) => { 35 | const { endpoint, sharedPayload, events } = requestModel; 36 | const sharedPayloadString = new URLSearchParams(sharedPayload as any).toString(); // Cast to any to handle object to query string conversion 37 | const eventParams = events.map(e => new URLSearchParams(e as any).toString()); // Cast to any for the same reason 38 | 39 | const resource = events.length === 1 40 | ? `${endpoint}?${sharedPayloadString}&${eventParams[0]}` 41 | : `${endpoint}?${sharedPayloadString}`; 42 | 43 | const options: RequestInit = { 44 | method: 'POST', 45 | body: events.length > 1 ? eventParams.join('\r\n') : null 46 | }; 47 | 48 | return { resource, options }; 49 | }; 50 | 51 | const clonedRequest: RequestModel = JSON.parse(JSON.stringify(request)); 52 | 53 | const filterEvents = ( 54 | events: Event[], 55 | whiteListedEvents?: string[], 56 | blackListedEvents?: string[] 57 | ): Event[] => { 58 | const validateEventsArray = (list?: string[]): list is string[] => Array.isArray(list) && list.length > 0; 59 | 60 | const applyFilter = (events: Event[], list: Set | null, keep: boolean): Event[] => { 61 | if (!list) return events; 62 | return events.filter(event => keep ? list.has(event.en) : !list.has(event.en)); 63 | }; 64 | 65 | if (validateEventsArray(whiteListedEvents)) { 66 | return applyFilter(events, new Set(whiteListedEvents), true); 67 | } else if (validateEventsArray(blackListedEvents)) { 68 | return applyFilter(events, new Set(blackListedEvents), false); 69 | } else { 70 | return events; 71 | } 72 | }; 73 | 74 | clonedRequest.events = filterEvents( 75 | clonedRequest.events, 76 | whiteListedEvents, 77 | blackListedEvents 78 | ); 79 | 80 | if (clonedRequest.events.length > 0) { 81 | toMeasurementIds.forEach(id => { 82 | if (!/^(G-|MC-)[A-Z0-9]+$/.test(id)) { 83 | console.error('Invalid Measurement ID format, skipping:', id); 84 | } else { 85 | clonedRequest.sharedPayload.tid = id; 86 | 87 | const req = buildFetchRequest(clonedRequest); 88 | if (window.GA4CustomTask?.originalFetch) { 89 | window.GA4CustomTask.originalFetch(req.resource, req.options); 90 | } else { 91 | console.error('GA4CustomTask.originalFetch is not defined.'); 92 | } 93 | } 94 | }); 95 | } 96 | 97 | return request; 98 | }; 99 | 100 | export default sendToSecondaryMeasurementIdTask; 101 | -------------------------------------------------------------------------------- /dist/tasks/sendToSecondaryMeasurementIdTask.js: -------------------------------------------------------------------------------- 1 | var sendToSecondaryMeasurementIdTask = (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * Sends a copy to secondary Measurement Ids 6 | * You can control which events to copy 7 | * 8 | * @param request - The request model to be modified. 9 | * @param toMeasurementIds - Array of Measurement IDs to send a copy to 10 | * @param whiteListedEvents - Array of event names that will be kept 11 | * @param blackListedEvents - Array of event names that will be removed 12 | * @returns The modified request model. 13 | */ 14 | var sendToSecondaryMeasurementIdTask = function (request, toMeasurementIds, whiteListedEvents, blackListedEvents) { 15 | if (whiteListedEvents === void 0) { whiteListedEvents = []; } 16 | if (blackListedEvents === void 0) { blackListedEvents = []; } 17 | if (!request || !toMeasurementIds || !Array.isArray(toMeasurementIds) || toMeasurementIds.length === 0) { 18 | console.error('sendToSecondaryMeasurementIdTask: Request and Measurement IDs are required.'); 19 | return request; 20 | } 21 | var buildFetchRequest = function (requestModel) { 22 | var endpoint = requestModel.endpoint, sharedPayload = requestModel.sharedPayload, events = requestModel.events; 23 | var sharedPayloadString = new URLSearchParams(sharedPayload).toString(); // Cast to any to handle object to query string conversion 24 | var eventParams = events.map(function (e) { return new URLSearchParams(e).toString(); }); // Cast to any for the same reason 25 | var resource = events.length === 1 26 | ? "".concat(endpoint, "?").concat(sharedPayloadString, "&").concat(eventParams[0]) 27 | : "".concat(endpoint, "?").concat(sharedPayloadString); 28 | var options = { 29 | method: 'POST', 30 | body: events.length > 1 ? eventParams.join('\r\n') : null 31 | }; 32 | return { resource: resource, options: options }; 33 | }; 34 | var clonedRequest = JSON.parse(JSON.stringify(request)); 35 | var filterEvents = function (events, whiteListedEvents, blackListedEvents) { 36 | var validateEventsArray = function (list) { return Array.isArray(list) && list.length > 0; }; 37 | var applyFilter = function (events, list, keep) { 38 | if (!list) 39 | return events; 40 | return events.filter(function (event) { return keep ? list.has(event.en) : !list.has(event.en); }); 41 | }; 42 | if (validateEventsArray(whiteListedEvents)) { 43 | return applyFilter(events, new Set(whiteListedEvents), true); 44 | } 45 | else if (validateEventsArray(blackListedEvents)) { 46 | return applyFilter(events, new Set(blackListedEvents), false); 47 | } 48 | else { 49 | return events; 50 | } 51 | }; 52 | clonedRequest.events = filterEvents(clonedRequest.events, whiteListedEvents, blackListedEvents); 53 | if (clonedRequest.events.length > 0) { 54 | toMeasurementIds.forEach(function (id) { 55 | var _a; 56 | if (!/^(G-|MC-)[A-Z0-9]+$/.test(id)) { 57 | console.error('Invalid Measurement ID format, skipping:', id); 58 | } 59 | else { 60 | clonedRequest.sharedPayload.tid = id; 61 | var req = buildFetchRequest(clonedRequest); 62 | if ((_a = window.GA4CustomTask) === null || _a === void 0 ? void 0 : _a.originalFetch) { 63 | window.GA4CustomTask.originalFetch(req.resource, req.options); 64 | } 65 | else { 66 | console.error('GA4CustomTask.originalFetch is not defined.'); 67 | } 68 | } 69 | }); 70 | } 71 | return request; 72 | }; 73 | 74 | return sendToSecondaryMeasurementIdTask; 75 | 76 | })(); 77 | -------------------------------------------------------------------------------- /dist/tasks/preventDuplicateTransactionsTask.js: -------------------------------------------------------------------------------- 1 | var preventDuplicateTransactionsTask = (function () { 2 | 'use strict'; 3 | 4 | var storageHelper = { 5 | sync: function (name) { 6 | var cookie = document.cookie.split('; ').find(function (row) { return row.startsWith(name + '='); }); 7 | var valueFromLocalStorage = localStorage.getItem(name); 8 | var valueFromCookie = cookie ? cookie.split('=')[1] : null; 9 | if (valueFromCookie !== null) { 10 | var decodedValue = decodeURIComponent(valueFromCookie); 11 | var timestampFromCookie = parseInt(decodedValue.split(':')[0], 10); 12 | var timestampFromLocalStorage = valueFromLocalStorage ? parseInt(valueFromLocalStorage.split(':')[0], 10) : 0; 13 | if (timestampFromCookie > timestampFromLocalStorage) { 14 | localStorage.setItem(name, decodedValue); 15 | } 16 | else if (timestampFromLocalStorage > timestampFromCookie) { 17 | if (valueFromLocalStorage !== null) { 18 | document.cookie = "".concat(name, "=").concat(encodeURIComponent(valueFromLocalStorage), "; path=/; SameSite=Strict; Secure"); 19 | } 20 | } 21 | } 22 | else { 23 | // Handle case where cookie is null 24 | if (valueFromLocalStorage !== null) { 25 | document.cookie = "".concat(name, "=").concat(encodeURIComponent(valueFromLocalStorage), "; path=/; SameSite=Strict; Secure"); 26 | } 27 | } 28 | }, 29 | get: function (name) { 30 | this.sync(name); 31 | var value = localStorage.getItem(name); 32 | return value ? value.split(':')[1] || null : null; 33 | }, 34 | set: function (name, value, days, path, domain) { 35 | if (days === void 0) { days = 7; } 36 | if (path === void 0) { path = '/'; } 37 | if (domain === void 0) { domain = ''; } 38 | var timestamp = Date.now(); 39 | var valueWithTimestamp = "".concat(timestamp, ":").concat(value); 40 | var encodedValue = encodeURIComponent(valueWithTimestamp); 41 | localStorage.setItem(name, valueWithTimestamp); 42 | var expires = new Date(Date.now() + days * 864e5).toUTCString(); 43 | document.cookie = "".concat(name, "=").concat(encodedValue, "; expires=").concat(expires, "; path=").concat(path, "; domain=").concat(domain, "; SameSite=Strict; Secure"); 44 | } 45 | }; 46 | 47 | /** 48 | * Monitors purchase events and keeps track of transaction IDs to prevent duplicate transactions. 49 | * Used a synced dual localStorage and cookie storage to store transaction IDs. 50 | * 51 | * @param request - The request model to be modified. 52 | * @param storeName - The storage type to be used. Defaults to 'cookie'. 53 | * @returns The modified payload object. 54 | */ 55 | var preventDuplicateTransactionsTask = function (request, storeName) { 56 | if (storeName === void 0) { storeName = '__ad_trans_dedup'; } 57 | if (!request) { 58 | console.error('preventDuplicateTransactionsTask: Request is required.'); 59 | return request; 60 | } 61 | var hasPurchaseEvents = request.events.some(function (e) { return e.en === 'purchase'; }); 62 | if (hasPurchaseEvents) { 63 | var alreadyTrackedTransactions_1 = []; 64 | try { 65 | var storedTransactions = storageHelper.get(storeName); 66 | alreadyTrackedTransactions_1 = storedTransactions ? JSON.parse(storedTransactions) : []; 67 | } 68 | catch (error) { 69 | console.error('Failed to parse stored transactions:', error); 70 | } 71 | // Remove duplicate purchase events 72 | request.events = request.events.filter(function (event) { 73 | if (event.en === 'purchase' && alreadyTrackedTransactions_1.includes(event['ep.transaction_id'])) { 74 | return false; // Remove this event 75 | } 76 | alreadyTrackedTransactions_1.push(event['ep.transaction_id']); // Add to tracked transactions 77 | return true; // Keep this event 78 | }); 79 | // Write the new transactions to Storage 80 | storageHelper.set(storeName, JSON.stringify(alreadyTrackedTransactions_1)); 81 | } 82 | if (request.events.length === 0) { 83 | // So it seems that all events were removed, there's no need to even sent this request 84 | request.__skip = true; 85 | } 86 | return request; 87 | }; 88 | 89 | return preventDuplicateTransactionsTask; 90 | 91 | })(); 92 | -------------------------------------------------------------------------------- /tasks/piiScrubberTask/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Scrubs PII from the request payload. 3 | * 4 | * @param request - The request model to be modified. 5 | * @param queryParamsBlackList - Array of parameters to be removed (From URL-like values). 6 | * @param scrubPatternsList - Array of patterns (regex) to match and scrub, defaults to email. 7 | * @param callback - Function to be called after scrubbing with the list of scrubbed fields. 8 | * @param logScrubbing - Whether to log scrubbing details. 9 | * @returns The modified request model. 10 | */ 11 | const piiScrubberTask = ( 12 | request: RequestModel, 13 | queryParamsBlackList: string[] = [], 14 | scrubPatternsList: ScrubPattern[] = [], 15 | callback?: (scrubbedValues: object) => void, 16 | logScrubbing: boolean = false 17 | ): RequestModel => { 18 | if (!request) { 19 | console.error('piiScrubberTask: Request is required.'); 20 | return request; 21 | } 22 | 23 | const isUrlWithOptionalQuery = (value: string): boolean => { 24 | return /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:\/\/[^\s]*)(?:\?[^\s]*)?$/.test(value); 25 | }; 26 | 27 | const blackListedQueryStringParameters = ['email', 'mail', 'name', 'surname'].concat(queryParamsBlackList).filter(Boolean); 28 | const defaultScrubPatterns: ScrubPattern[] = [{ 29 | id: 'email', 30 | regex: /[a-zA-Z0-9-_.]+@[a-zA-Z0-9-_.]+/, 31 | replacement: '[email_redacted]' 32 | }]; 33 | 34 | const scrubPatterns = defaultScrubPatterns.concat(scrubPatternsList).filter(Boolean); 35 | 36 | const scrubbedValues: { [key: string]: any } = {}; 37 | const scrubbedFields: string[] = []; 38 | 39 | const scrubData = (data: { [key: string]: any } | null, origin: string) => { 40 | if (data === null) { 41 | console.warn(`Skipping scrubbing for ${origin} as data is null`); 42 | return; 43 | } 44 | 45 | Object.entries(data).forEach(([key, value]) => { 46 | scrubPatterns.forEach(({ id, regex, replacement }) => { 47 | if (typeof value === 'string' && regex.test(value)) { 48 | scrubbedValues[key] = { 49 | origin, 50 | rule: `matched pattern: ${id}`, 51 | original: value, 52 | scrubbed: value.replace(regex, replacement) 53 | }; 54 | data[key] = value.replace(regex, replacement); 55 | scrubbedFields.push(key); 56 | } 57 | }); 58 | 59 | if (typeof value === 'string' && isUrlWithOptionalQuery(value)) { 60 | try { 61 | const url = new URL(value); 62 | const params = new URLSearchParams(url.search); 63 | 64 | // Process blacklisted parameters 65 | blackListedQueryStringParameters.forEach(param => { 66 | if (params.has(param)) { 67 | params.set(param, 'parameter_removed'); 68 | scrubbedValues[key] = { 69 | origin, 70 | rule: 'blacklisted parameter', 71 | original: value, 72 | scrubbed: url.toString() 73 | }; 74 | scrubbedFields.push(param); 75 | } 76 | }); 77 | 78 | // Process query string values for scrub patterns 79 | params.forEach((value, param) => { 80 | scrubPatterns.forEach(({ id, regex, replacement }) => { 81 | if (regex.test(value)) { 82 | const scrubbedValue = value.replace(regex, replacement); 83 | params.set(param, scrubbedValue); 84 | scrubbedValues[key] = { 85 | origin, 86 | rule: `matched pattern: ${id}`, 87 | original: url.toString(), 88 | scrubbed: scrubbedValue 89 | }; 90 | scrubbedFields.push(param); 91 | } 92 | }); 93 | }); 94 | 95 | // Update the URL's search parameters and assign it to data 96 | url.search = params.toString(); 97 | data[key] = url.toString(); 98 | } catch (e) { 99 | console.error('Invalid URL:', value, e); 100 | } 101 | } 102 | }); 103 | }; 104 | 105 | 106 | // Scrub sharedPayload 107 | scrubData(request.sharedPayload, 'sharedPayload'); 108 | 109 | // Scrub events 110 | request.events.forEach(event => scrubData(event, 'events')); 111 | 112 | // Call the callback if it's a function 113 | if (callback && scrubbedFields.length > 0) { 114 | callback(scrubbedValues); 115 | } 116 | 117 | // Log scrubbing details if required 118 | if (logScrubbing && Object.keys(scrubbedValues).length > 0) { 119 | console.groupCollapsed('PII Scrubber: Scrubbed Data'); 120 | console.log(scrubbedValues); 121 | console.groupEnd(); 122 | } 123 | 124 | return request; 125 | }; 126 | 127 | export default piiScrubberTask; 128 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import isGA4Hit from './helpers/isGA4Hit'; 2 | let interceptors: Interceptor[] = []; 3 | type FetchArgs = [RequestInfo | URL, RequestInit?]; 4 | 5 | // Interceptor function to handle fetch requests and responses 6 | function interceptor(fetch: typeof window.fetch, args: FetchArgs): Promise { 7 | const reversedInterceptors = interceptors.reduce( 8 | (array, interceptor) => [interceptor].concat(array), 9 | [] 10 | ); 11 | 12 | let promise: Promise = Promise.resolve(args); 13 | 14 | // Apply request interceptors (resolve to FetchArgs) 15 | reversedInterceptors.forEach(({ request, requestError }) => { 16 | if (request || requestError) { 17 | promise = promise.then( 18 | args => (request ? request(...args) : args), 19 | requestError 20 | ); 21 | } 22 | }); 23 | 24 | // Proceed with the original fetch call (resolve to Response) 25 | let responsePromise: Promise = promise.then(args => fetch(args[0], args[1])); 26 | 27 | // Apply response interceptors (resolve to Response) 28 | reversedInterceptors.forEach(({ response, responseError }) => { 29 | if (response || responseError) { 30 | responsePromise = responsePromise.then(response, responseError); 31 | } 32 | }); 33 | 34 | return responsePromise; 35 | } 36 | 37 | const GA4CustomTask = function (settings: GA4CustomTaskSettings) { 38 | if (!settings) return; 39 | interceptors.push({ 40 | request: function (resource: URL | RequestInfo, options: RequestInit = {}): FetchArgs { 41 | try { 42 | if (typeof resource === 'string' && isGA4Hit(resource)) { 43 | const url = new URL(resource); 44 | let RequestModel: RequestModel = { 45 | endpoint: url.origin + url.pathname, 46 | sharedPayload: {}, 47 | events: [], 48 | }; 49 | 50 | const payloadArray = Array.from(new URLSearchParams(url.search).entries()); 51 | 52 | if (!options.body) { 53 | RequestModel.sharedPayload = Object.fromEntries( 54 | payloadArray.slice(0, payloadArray.findIndex(([key]) => key === 'en')) 55 | ); 56 | RequestModel.events = [ 57 | Object.fromEntries(payloadArray.slice(payloadArray.findIndex(([key]) => key === 'en'))) 58 | ]; 59 | } else { 60 | RequestModel.sharedPayload = Object.fromEntries(payloadArray); 61 | RequestModel.events = (options.body as string) 62 | .split('\r\n') 63 | .map(e => Object.fromEntries(new URLSearchParams(e).entries())); 64 | } 65 | 66 | const payload = Object.fromEntries(new URLSearchParams(url.search)); 67 | 68 | if ( 69 | settings.allowedMeasurementIds && 70 | Array.isArray(settings.allowedMeasurementIds) && 71 | !settings.allowedMeasurementIds.includes(payload['tid']) 72 | ) { 73 | return [resource, options] as FetchArgs; 74 | } 75 | 76 | if (Array.isArray(settings.tasks)) { 77 | settings.tasks.forEach(callback => { 78 | if (typeof callback === 'function') { 79 | RequestModel = callback.call({ originalFetch: GA4CustomTask.originalFetch }, RequestModel); 80 | } else { 81 | console.warn('Callback is not a function:', callback); 82 | } 83 | }); 84 | } 85 | 86 | const reBuildResource = (model: RequestModel) => { 87 | const resourceString = new URLSearchParams(model.sharedPayload || {}).toString(); 88 | const bodyString = model.events.map(e => new URLSearchParams(e).toString()).join('\r\n'); 89 | return { 90 | endpoint: model.endpoint, 91 | resource: resourceString, 92 | body: bodyString, 93 | }; 94 | }; 95 | 96 | const newResource = reBuildResource(RequestModel); 97 | 98 | if (options.body) { 99 | resource = `${newResource.endpoint}?${newResource.resource}`; 100 | options.body = newResource.body; 101 | } else { 102 | resource = `${newResource.endpoint}?${newResource.resource}&${newResource.body}`; 103 | } 104 | } 105 | } catch (e) { 106 | console.error('Error in fetch interceptor:', e); 107 | } 108 | return [resource, options] as FetchArgs; 109 | }, 110 | response: function (response: Response) { 111 | return response; 112 | }, 113 | responseError: function (error: any) { 114 | return Promise.reject(error); 115 | }, 116 | }); 117 | 118 | // Ensure fetch is available in the environment 119 | window.fetch = (function (fetch: typeof window.fetch) { 120 | return function (resource: RequestInfo | URL, options?: RequestInit) { 121 | const fetchArgs: FetchArgs = [resource, options]; 122 | return interceptor(fetch, fetchArgs); 123 | }; 124 | })(window.fetch); 125 | 126 | return { 127 | clear: function () { 128 | interceptors = []; 129 | }, 130 | }; 131 | }; 132 | 133 | // Add original fetch for TypeScript type safety 134 | GA4CustomTask.originalFetch = window.fetch; 135 | export default GA4CustomTask; -------------------------------------------------------------------------------- /dist/tasks/piiScrubberTask.js: -------------------------------------------------------------------------------- 1 | var piiScrubberTask = (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * Scrubs PII from the request payload. 6 | * 7 | * @param request - The request model to be modified. 8 | * @param queryParamsBlackList - Array of parameters to be removed (From URL-like values). 9 | * @param scrubPatternsList - Array of patterns (regex) to match and scrub, defaults to email. 10 | * @param callback - Function to be called after scrubbing with the list of scrubbed fields. 11 | * @param logScrubbing - Whether to log scrubbing details. 12 | * @returns The modified request model. 13 | */ 14 | var piiScrubberTask = function (request, queryParamsBlackList, scrubPatternsList, callback, logScrubbing) { 15 | if (queryParamsBlackList === void 0) { queryParamsBlackList = []; } 16 | if (scrubPatternsList === void 0) { scrubPatternsList = []; } 17 | if (logScrubbing === void 0) { logScrubbing = false; } 18 | if (!request) { 19 | console.error('piiScrubberTask: Request is required.'); 20 | return request; 21 | } 22 | var isUrlWithOptionalQuery = function (value) { 23 | return /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:\/\/[^\s]*)(?:\?[^\s]*)?$/.test(value); 24 | }; 25 | var blackListedQueryStringParameters = ['email', 'mail', 'name', 'surname'].concat(queryParamsBlackList).filter(Boolean); 26 | var defaultScrubPatterns = [{ 27 | id: 'email', 28 | regex: /[a-zA-Z0-9-_.]+@[a-zA-Z0-9-_.]+/, 29 | replacement: '[email_redacted]' 30 | }]; 31 | var scrubPatterns = defaultScrubPatterns.concat(scrubPatternsList).filter(Boolean); 32 | var scrubbedValues = {}; 33 | var scrubbedFields = []; 34 | var scrubData = function (data, origin) { 35 | if (data === null) { 36 | console.warn("Skipping scrubbing for ".concat(origin, " as data is null")); 37 | return; 38 | } 39 | Object.entries(data).forEach(function (_a) { 40 | var key = _a[0], value = _a[1]; 41 | scrubPatterns.forEach(function (_a) { 42 | var id = _a.id, regex = _a.regex, replacement = _a.replacement; 43 | if (typeof value === 'string' && regex.test(value)) { 44 | scrubbedValues[key] = { 45 | origin: origin, 46 | rule: "matched pattern: ".concat(id), 47 | original: value, 48 | scrubbed: value.replace(regex, replacement) 49 | }; 50 | data[key] = value.replace(regex, replacement); 51 | scrubbedFields.push(key); 52 | } 53 | }); 54 | if (typeof value === 'string' && isUrlWithOptionalQuery(value)) { 55 | try { 56 | var url_1 = new URL(value); 57 | var params_1 = new URLSearchParams(url_1.search); 58 | // Process blacklisted parameters 59 | blackListedQueryStringParameters.forEach(function (param) { 60 | if (params_1.has(param)) { 61 | params_1.set(param, 'parameter_removed'); 62 | scrubbedValues[key] = { 63 | origin: origin, 64 | rule: 'blacklisted parameter', 65 | original: value, 66 | scrubbed: url_1.toString() 67 | }; 68 | scrubbedFields.push(param); 69 | } 70 | }); 71 | // Process query string values for scrub patterns 72 | params_1.forEach(function (value, param) { 73 | scrubPatterns.forEach(function (_a) { 74 | var id = _a.id, regex = _a.regex, replacement = _a.replacement; 75 | if (regex.test(value)) { 76 | var scrubbedValue = value.replace(regex, replacement); 77 | params_1.set(param, scrubbedValue); 78 | scrubbedValues[key] = { 79 | origin: origin, 80 | rule: "matched pattern: ".concat(id), 81 | original: url_1.toString(), 82 | scrubbed: scrubbedValue 83 | }; 84 | scrubbedFields.push(param); 85 | } 86 | }); 87 | }); 88 | // Update the URL's search parameters and assign it to data 89 | url_1.search = params_1.toString(); 90 | data[key] = url_1.toString(); 91 | } 92 | catch (e) { 93 | console.error('Invalid URL:', value, e); 94 | } 95 | } 96 | }); 97 | }; 98 | // Scrub sharedPayload 99 | scrubData(request.sharedPayload, 'sharedPayload'); 100 | // Scrub events 101 | request.events.forEach(function (event) { return scrubData(event, 'events'); }); 102 | // Call the callback if it's a function 103 | if (callback && scrubbedFields.length > 0) { 104 | callback(scrubbedValues); 105 | } 106 | // Log scrubbing details if required 107 | if (logScrubbing && Object.keys(scrubbedValues).length > 0) { 108 | console.groupCollapsed('PII Scrubber: Scrubbed Data'); 109 | console.log(scrubbedValues); 110 | console.groupEnd(); 111 | } 112 | return request; 113 | }; 114 | 115 | return piiScrubberTask; 116 | 117 | })(); 118 | -------------------------------------------------------------------------------- /dist/GA4CustomTask.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 3 | typeof define === 'function' && define.amd ? define(factory) : 4 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.GA4CustomTask = factory()); 5 | })(this, (function () { 'use strict'; 6 | 7 | // Check if the URL belongs to GA4 8 | function isGA4Hit(url) { 9 | try { 10 | var urlObj = new URL(url); 11 | var params = new URLSearchParams(urlObj.search); 12 | var tid = params.get('tid'); 13 | var cid = params.get('cid'); 14 | var v = params.get('v'); 15 | return !!tid && tid.startsWith('G-') && !!cid && v === '2'; 16 | } 17 | catch (e) { 18 | console.error('Error parsing URL:', e); 19 | return false; 20 | } 21 | } 22 | 23 | var interceptors = []; 24 | // Interceptor function to handle fetch requests and responses 25 | function interceptor(fetch, args) { 26 | var reversedInterceptors = interceptors.reduce(function (array, interceptor) { return [interceptor].concat(array); }, []); 27 | var promise = Promise.resolve(args); 28 | // Apply request interceptors (resolve to FetchArgs) 29 | reversedInterceptors.forEach(function (_a) { 30 | var request = _a.request, requestError = _a.requestError; 31 | if (request || requestError) { 32 | promise = promise.then(function (args) { return (request ? request.apply(void 0, args) : args); }, requestError); 33 | } 34 | }); 35 | // Proceed with the original fetch call (resolve to Response) 36 | var responsePromise = promise.then(function (args) { return fetch(args[0], args[1]); }); 37 | // Apply response interceptors (resolve to Response) 38 | reversedInterceptors.forEach(function (_a) { 39 | var response = _a.response, responseError = _a.responseError; 40 | if (response || responseError) { 41 | responsePromise = responsePromise.then(response, responseError); 42 | } 43 | }); 44 | return responsePromise; 45 | } 46 | var GA4CustomTask = function (settings) { 47 | if (!settings) 48 | return; 49 | interceptors.push({ 50 | request: function (resource, options) { 51 | if (options === void 0) { options = {}; } 52 | try { 53 | if (typeof resource === 'string' && isGA4Hit(resource)) { 54 | var url = new URL(resource); 55 | var RequestModel_1 = { 56 | endpoint: url.origin + url.pathname, 57 | sharedPayload: {}, 58 | events: [], 59 | }; 60 | var payloadArray = Array.from(new URLSearchParams(url.search).entries()); 61 | if (!options.body) { 62 | RequestModel_1.sharedPayload = Object.fromEntries(payloadArray.slice(0, payloadArray.findIndex(function (_a) { 63 | var key = _a[0]; 64 | return key === 'en'; 65 | }))); 66 | RequestModel_1.events = [ 67 | Object.fromEntries(payloadArray.slice(payloadArray.findIndex(function (_a) { 68 | var key = _a[0]; 69 | return key === 'en'; 70 | }))) 71 | ]; 72 | } 73 | else { 74 | RequestModel_1.sharedPayload = Object.fromEntries(payloadArray); 75 | RequestModel_1.events = options.body 76 | .split('\r\n') 77 | .map(function (e) { return Object.fromEntries(new URLSearchParams(e).entries()); }); 78 | } 79 | var payload = Object.fromEntries(new URLSearchParams(url.search)); 80 | if (settings.allowedMeasurementIds && 81 | Array.isArray(settings.allowedMeasurementIds) && 82 | !settings.allowedMeasurementIds.includes(payload['tid'])) { 83 | return [resource, options]; 84 | } 85 | if (Array.isArray(settings.tasks)) { 86 | settings.tasks.forEach(function (callback) { 87 | if (typeof callback === 'function') { 88 | RequestModel_1 = callback.call({ originalFetch: GA4CustomTask.originalFetch }, RequestModel_1); 89 | } 90 | else { 91 | console.warn('Callback is not a function:', callback); 92 | } 93 | }); 94 | } 95 | var reBuildResource = function (model) { 96 | var resourceString = new URLSearchParams(model.sharedPayload || {}).toString(); 97 | var bodyString = model.events.map(function (e) { return new URLSearchParams(e).toString(); }).join('\r\n'); 98 | return { 99 | endpoint: model.endpoint, 100 | resource: resourceString, 101 | body: bodyString, 102 | }; 103 | }; 104 | var newResource = reBuildResource(RequestModel_1); 105 | if (options.body) { 106 | resource = "".concat(newResource.endpoint, "?").concat(newResource.resource); 107 | options.body = newResource.body; 108 | } 109 | else { 110 | resource = "".concat(newResource.endpoint, "?").concat(newResource.resource, "&").concat(newResource.body); 111 | } 112 | } 113 | } 114 | catch (e) { 115 | console.error('Error in fetch interceptor:', e); 116 | } 117 | return [resource, options]; 118 | }, 119 | response: function (response) { 120 | return response; 121 | }, 122 | responseError: function (error) { 123 | return Promise.reject(error); 124 | }, 125 | }); 126 | // Ensure fetch is available in the environment 127 | window.fetch = (function (fetch) { 128 | return function (resource, options) { 129 | var fetchArgs = [resource, options]; 130 | return interceptor(fetch, fetchArgs); 131 | }; 132 | })(window.fetch); 133 | return { 134 | clear: function () { 135 | interceptors = []; 136 | }, 137 | }; 138 | }; 139 | // Add original fetch for TypeScript type safety 140 | GA4CustomTask.originalFetch = window.fetch; 141 | 142 | return GA4CustomTask; 143 | 144 | })); 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Return Of The Custom Task 3 |
4 | Leveraging Fetch Inteceptors to replicate the old friend Custom Task :) 5 |

6 | 7 |

8 | Version 9 | NPM version 10 | Tasks Count 11 | GitHub stars 12 |
13 | GitHub stars 14 |

15 |

16 |

Sponsor/Donate

17 | Become a Sponsor to support my work: GITHUB Sponsor 18 |

19 | 20 | # Why this library 21 | 22 | One of the best additions to Universal Analytics in the past was the Tasks, more specifically the customTask that allowed us to modify the hits payloads before theywere sent to the endpoint. 23 | 24 | In April 2024, Google Switched the use of sendBeacon to the fetch API. ( not a breaking change since sendBeacon seems to work on top of Fetch ), and along with the chance 25 | 26 | of reading the response from the requests, it allows to intercept the requests and modify the data before it gets send. Which is you read it right it's the same as the customTask used to work like. 27 | 28 | That's why I build a Fetch Interceptor that will allow you to apply custom functions to your payloads, for example for automatically adding the clientId as a event parameter 29 | 30 | or user property, sending a duplicate hit to a secondary account or preventing the duplicate transactions. 31 | The current features are: 32 | 33 | - Grab the current Payload and perform any action over it before it gets sent 34 | - Allows callbacks concatenations 35 | - Measurement ID based setup ( apply the callbacks only to the defined hits ) 36 | 37 | Not only this, I tool some time to port and improve all the customTask I was able to find around on internet so you can just use them on your setup. 38 | 39 | # How to Use It 40 | 41 | One we have loaded the GA4CustomTask.js code we need to instanciate our Tasker: 42 | 43 | ``` 44 | var ga4CustomTaskInterceptor = new GA4CustomTask({ 45 | allowedMeasurementIds: ["G-DEBUGEMALL"], 46 | tasks: [ 47 | logRequestsToConsoleTask, 48 | task2, 49 | task3 50 | ] 51 | }); 52 | ``` 53 | 54 | Since this is shared between all instances in your pages, you need to specify at least one allowedMeasurementIds, this way only these requests will be intercepted. 55 | 56 | Also, if there's no tasks nothing will be intercepted. 57 | 58 | # Building your own Task 59 | 60 | You can create your own custom tasks. By default, the library provides a `RequestModel` interface, which includes `sharedPayload` and `events` in object format. The library handles: 61 | 62 | - Parsing the request type (GET/POST) 63 | - Managing multiple events 64 | - Constructing the final fetch request for your convenience 65 | 66 | ``` 67 | interface RequestModel { 68 | endpoint: string; 69 | sharedPayload: { [key: string]: any }; // No need for null in the type 70 | events: { [key: string]: any }[]; 71 | __skip?: boolean; 72 | } 73 | ``` 74 | 75 | ``` 76 | const myCustomTask = (request: RequestModel): RequestModel => { 77 | // Your Code 78 | return request; 79 | }; 80 | ``` 81 | 82 | The task expects you to return the RequestModel back. 83 | This is a very simple example that just logs the requests 84 | 85 | ``` 86 | var mySimpleCustomTask = (request) => { 87 | console.log("NEW GA4 REQUEST", request); 88 | return request; 89 | } 90 | ``` 91 | 92 | If you need to pass parameters 93 | 94 | ``` 95 | const myCustomTaskWithParams = (request: RequestModel, name: string): RequestModel => { 96 | // your logic 97 | return request; 98 | }; 99 | ``` 100 | 101 | ``` 102 | var GA4CustomTaskInstance = new GA4CustomTask({ 103 | allowedMeasurementIds: ["G-DEBUGEMALL"], 104 | tasks: [ 105 | (requestModel) => myCustomTaskWithParams(requestModel, 'myNameParamValue'), 106 | ] 107 | }); 108 | ``` 109 | 110 | Original Fetch object is available at ```GA4CustomTask.originalFetch``` for your convenience. 111 | You can take a look to tasks folder to see more examples. 112 | 113 | # Available Tasks List 114 | 115 | ||Task Name|Description| 116 | |-|------------|--| 117 | |#1|[logRequestsToConsoleTask](tasks/logRequestsToConsoleTask)|Logs all requests to the console, for debugging pourposes.| 118 | |#2|[mapClientIdTask](tasks/mapClientIdTask)|Grabs the clientId (&cid) and attaches the value to the specified parameter.| 119 | |#3|[mapPayloadSizeTask](tasks/mapPayloadSizeTask)|Attaches the current payload size to the specified parameter.| 120 | |#4|[preventDuplicateTransactionsTask](tasks/preventDuplicateTransactionsTask)|Prevents Duplicate Purchases/transaations keeping a list of transactions on the cookies/localStorage.| 121 | |#5|[snowPlowStreamingTask](tasks/snowPlowStreamingTask)|Sends a copy of the payload to your SnowPlow Collector.| 122 | |#6|[sendToSecondaryMeasurementId](tasks/logRequestssendToSecondaryMeasurementIdoConsoleTask)|Sends a copy of the payload to a secondary account.| 123 | |#7|[piiScrubberTask](tasks/piiScrubberTask)|Loops all data in the payload redacting the PII Data.| 124 | |#8|[privacySweepTask](tasks/privacySweepTask)|Cleans Up all non "Analytics" related parameters/ids.| 125 | |#9|[eventBouncerTask](tasks/eventBouncerTask)|Blocks the unwanted events and clean up the non-expected parameters.| 126 | 127 | ## Adding / Loading Tasks 128 | 129 | We provide several ready to use tasks, you'll find them within the dist/tasks folder, they are offered in normal and minified version. 130 | 131 | ### Adding a Task that doens't need parameters 132 | 133 | ``` 134 | var logRequestsToConsoleTask = () => {...} // Copy from dist folder 135 | const ga4CustomTaskInterceptor = new GA4CustomTask({ 136 | allowedMeasurementIds: ["G-DEBUGEMALL"], 137 | tasks: [ 138 | logRequestsToConsoleTask, 139 | ] 140 | }); 141 | ``` 142 | 143 | ### Adding a Task that used 144 | 145 | Some tasks may require parameters, In that case we'll need to pass the paremter this way 146 | 147 | ``` 148 | const mapClientIdTask = () => {...} // Copy from dist folder 149 | const ga4CustomTaskInterceptor = new GA4CustomTask({ 150 | allowedMeasurementIds: ["G-DEBUGEMALL"], 151 | tasks: [ 152 | (request) => mapClientIdTask(request, 'client_id') 153 | ] 154 | }); 155 | ``` 156 | 157 | In GTM we woh't be able to use arrow functions or const 158 | 159 | ``` 160 | var mapClientIdTask = function(payload, clientId) { 161 | // Function body copied from dist folder 162 | }; 163 | var ga4CustomTaskInterceptor = new GA4CustomTask({ 164 | allowedMeasurementIds: ["G-DEBUGEMALL"], 165 | tasks: [ 166 | function(request) { 167 | return mapClientIdTask(request, 'client_id'); 168 | } 169 | ] 170 | }); 171 | ``` 172 | 173 | ## Stacking / Chaining Tasks 174 | 175 | You may add as many tasks as you want, but remember they will be execute secuencially, so apply them wisely. 176 | 177 | # The Request Model 178 | 179 | Working with Google Analytics 4 (GA4) is more complex than with Universal Analytics, mainly because a GA4 request can contain multiple events. This makes it impossible to work with just a single payload. To simplify things, this library automatically splits and parses the request for you. It takes the current GA4 request and builds a `requestModel`, which includes the shared payload and the event details. 180 | 181 | You don’t need to worry about parsing the request. If the request doesn’t have a body, the library will handle splitting the main payload and return a single event. 182 | 183 | ```json 184 | requestModel: { 185 | sharedPayload: {}, 186 | events: [{}, {}] 187 | } 188 | ``` 189 | 190 | # Support 191 | 192 | Donations Accepted 193 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------