├── .all-contributorsrc
├── .eslintrc.js
├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── .npmignore
├── .prettierrc
├── LICENSE
├── README.md
├── assets
└── google_analytics-4.svg
├── esbuild.js
├── manifest.json
├── package-lock.json
├── package.json
├── src
├── ecommerce.ts
├── index.ts
├── requestBuilder.ts
├── utils.test.ts
└── utils.ts
├── tsconfig.build.json
└── tsconfig.json
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "projectName": "@managed-components/google-analytics-4",
3 | "projectOwner": "managed-components",
4 | "repoType": "github",
5 | "repoHost": "https://github.com",
6 | "files": [
7 | "README.md"
8 | ],
9 | "imageSize": 75,
10 | "commit": true,
11 | "commitConvention": "none",
12 | "contributors": [
13 | {
14 | "login": "simonabadoiu",
15 | "name": "Simona Badoiu",
16 | "avatar_url": "https://avatars.githubusercontent.com/u/1610123?v=4",
17 | "profile": "https://github.com/simonabadoiu",
18 | "contributions": [
19 | "code"
20 | ]
21 | },
22 | {
23 | "login": "bjesus",
24 | "name": "Yo'av Moshe",
25 | "avatar_url": "https://avatars.githubusercontent.com/u/55081?v=4",
26 | "profile": "https://yoavmoshe.com/about",
27 | "contributions": [
28 | "code"
29 | ]
30 | },
31 | {
32 | "login": "jonnyparris",
33 | "name": "Ruskin",
34 | "avatar_url": "https://avatars.githubusercontent.com/u/6400000?v=4",
35 | "profile": "https://github.com/jonnyparris",
36 | "contributions": [
37 | "code"
38 | ]
39 | }
40 | ],
41 | "contributorsPerLine": 7
42 | }
43 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | extends: [
4 | 'eslint:recommended',
5 | 'plugin:@typescript-eslint/recommended',
6 | 'plugin:prettier/recommended',
7 | ],
8 | plugins: ['prettier'],
9 | env: {
10 | node: true,
11 | browser: true,
12 | worker: true,
13 | es2022: true,
14 | },
15 | rules: {
16 | 'prettier/prettier': 'error',
17 | 'no-unused-vars': 'off',
18 | '@typescript-eslint/no-unused-vars': [
19 | 'error',
20 | { ignoreRestSiblings: true, argsIgnorePattern: '^_' },
21 | ],
22 | },
23 | overrides: [
24 | {
25 | files: ['*d.ts'],
26 | rules: {
27 | 'no-undef': 'off',
28 | },
29 | },
30 | {
31 | files: ['*js'],
32 | globals: {
33 | webcm: 'writable',
34 | },
35 | },
36 | ],
37 | }
38 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: 'Build Test'
2 |
3 | on:
4 | push:
5 | branches:
6 | - '**'
7 |
8 | jobs:
9 | build-test:
10 | runs-on: ubuntu-latest
11 | strategy:
12 | matrix:
13 | node-version: [20.x]
14 | steps:
15 | - uses: actions/checkout@v2
16 | - name: Running Node.js ${{ matrix.node-version }}
17 | uses: actions/setup-node@v1
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | # Actual Tests
21 | - run: npm i
22 | - run: npm run lint --if-present
23 | - run: npm run typecheck --if-present
24 | - run: npm run bundle --if-present
25 | - run: npm run test --if-present
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/node
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node
3 |
4 | ### Node ###
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 | .pnpm-debug.log*
13 |
14 | # Diagnostic reports (https://nodejs.org/api/report.html)
15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
16 |
17 | # Runtime data
18 | pids
19 | *.pid
20 | *.seed
21 | *.pid.lock
22 |
23 | # Directory for instrumented libs generated by jscoverage/JSCover
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 | coverage
28 | *.lcov
29 |
30 | # nyc test coverage
31 | .nyc_output
32 |
33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
34 | .grunt
35 |
36 | # Bower dependency directory (https://bower.io/)
37 | bower_components
38 |
39 | # node-waf configuration
40 | .lock-wscript
41 |
42 | # Compiled binary addons (https://nodejs.org/api/addons.html)
43 | build/Release
44 |
45 | # Dependency directories
46 | node_modules/
47 | jspm_packages/
48 |
49 | # Snowpack dependency directory (https://snowpack.dev/)
50 | web_modules/
51 |
52 | # TypeScript cache
53 | *.tsbuildinfo
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # Optional stylelint cache
62 | .stylelintcache
63 |
64 | # Microbundle cache
65 | .rpt2_cache/
66 | .rts2_cache_cjs/
67 | .rts2_cache_es/
68 | .rts2_cache_umd/
69 |
70 | # Optional REPL history
71 | .node_repl_history
72 |
73 | # Output of 'npm pack'
74 | *.tgz
75 |
76 | # Yarn Integrity file
77 | .yarn-integrity
78 |
79 | # dotenv environment variable files
80 | .env
81 | .env.development.local
82 | .env.test.local
83 | .env.production.local
84 | .env.local
85 |
86 | # parcel-bundler cache (https://parceljs.org/)
87 | .cache
88 | .parcel-cache
89 |
90 | # Next.js build output
91 | .next
92 | out
93 |
94 | # Nuxt.js build / generate output
95 | .nuxt
96 | dist
97 |
98 | # Gatsby files
99 | .cache/
100 | # Comment in the public line in if your project uses Gatsby and not Next.js
101 | # https://nextjs.org/blog/next-9-1#public-directory-support
102 | # public
103 |
104 | # vuepress build output
105 | .vuepress/dist
106 |
107 | # vuepress v2.x temp and cache directory
108 | .temp
109 |
110 | # Docusaurus cache and generated files
111 | .docusaurus
112 |
113 | # Serverless directories
114 | .serverless/
115 |
116 | # FuseBox cache
117 | .fusebox/
118 |
119 | # DynamoDB Local files
120 | .dynamodb/
121 |
122 | # TernJS port file
123 | .tern-port
124 |
125 | # Stores VSCode versions used for testing VSCode extensions
126 | .vscode-test
127 |
128 | # yarn v2
129 | .yarn/cache
130 | .yarn/unplugged
131 | .yarn/build-state.yml
132 | .yarn/install-state.gz
133 | .pnp.*
134 |
135 | ### Node Patch ###
136 | # Serverless Webpack directories
137 | .webpack/
138 |
139 | # Optional stylelint cache
140 |
141 | # SvelteKit build / generate output
142 | .svelte-kit
143 |
144 | # End of https://www.toptal.com/developers/gitignore/api/node
145 |
146 | .vscode
147 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | src
2 | .all-contributorsrc
3 | .eslintrc.js
4 | .github
5 | .prettierrc
6 | esbuild.js
7 | tsconfig.build.json
8 | tsconfig.json
9 | coverage
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": false,
4 | "trailingComma": "es5",
5 | "tabWidth": 2,
6 | "printWidth": 80,
7 | "arrowParens": "avoid"
8 | }
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2022 Cloudflare, Inc.
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Google Analytics 4 Managed Component
2 |
3 | Find out more about Managed Components [here](https://blog.cloudflare.com/zaraz-open-source-managed-components-and-webcm/) for inspiration and motivation details.
4 |
5 |
6 |
7 | [](#contributors-)
8 |
9 |
10 |
11 | [](./LICENSE)
12 | [](./CONTRIBUTING.md)
13 | [](https://github.com/prettier/prettier)
14 |
15 | ## 🚀 Quickstart local dev environment
16 |
17 | 1. Make sure you're running node version >=18.
18 | 2. Install dependencies with `npm i`
19 | 3. Run unit test watcher with `npm run test:dev`
20 |
21 | ## Supported Event Types
22 |
23 | `pageview`, `ecommerce`, `event`
24 |
25 | ## ⚙️ Tool Settings
26 |
27 | > Settings are used to configure the tool in a Component Manager config file
28 |
29 | ### Measurement ID `string` _required_
30 |
31 | `tid` is the unique identifier of your Google Analytics 4 account. [Learn more](https://www.semrush.com/blog/google-analytics-tracking-id/#how-to-find-google-analytics-tracking-id)
32 |
33 | ### Hide Originating IP Address `boolean`
34 |
35 | `hideOriginalIP` will prevent sending the visitor IP address to Google Analytics 4
36 |
37 | ### E-commerce tracking `boolean`
38 |
39 | `ecommerce` Enable forwarding E-commerce events to Google Analytics as part of the enhanced e-commerce tracking feature. [Learn more](https://developers.google.com/analytics/devguides/collection/analyticsjs/enhanced-ecommerce#ecommerce-tracking)
40 |
41 | ### Google Analytics Audiences `boolean`
42 |
43 | `ga-audiences` enables/disables Audiences collection through Google Analytics
44 |
45 | ### Cookie Base Domain `string` _required_
46 |
47 | `baseDomain` manually set the domain for all Google Analytics cookies
48 |
49 | ## 🧱 Fields Description
50 |
51 | > Fields are properties that can/must be sent with certain events
52 |
53 | ### User ID/Visitor ID `string`
54 |
55 | `uid` lets you associate your own identifiers with individual users so you can connect their behavior across different sessions and on various devices and platforms. [Learn more](https://developers.google.com/analytics/devguides/collection/ga4/user-id?technology=gtagjs)
56 |
57 | ### Event Name `string`
58 |
59 | `en` will be sent as Event Name to Google Analytics. [Learn more](https://support.google.com/analytics/answer/1033068?hl=en)
60 |
61 | ### Non-interaction `boolean`
62 |
63 | `ni` events are not taken into account when Google Analytics calculates bounces and session duration. [Learn more](https://support.google.com/analytics/answer/1033068?hl=en#NonInteractionEvents)
64 |
65 | ### Custom Fields
66 |
67 | Custom fields can be used to send properties to Google Analytics. To specify user properties, please add the `up.` prefix to your property's name.
68 |
69 | ## 📝 License
70 |
71 | Licensed under the [Apache License](./LICENSE).
72 |
73 | ## 💜 Thanks
74 |
75 | Thanks to everyone contributing in any manner for this repo and to everyone working on Open Source in general.
76 |
77 | ## Contributors ✨
78 |
79 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
80 |
81 |
82 |
83 |
84 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
98 |
--------------------------------------------------------------------------------
/assets/google_analytics-4.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/esbuild.js:
--------------------------------------------------------------------------------
1 | require('esbuild').buildSync({
2 | entryPoints: ['src/index.ts'],
3 | bundle: true,
4 | minify: true,
5 | format: 'esm',
6 | platform: 'node',
7 | target: ['esnext'],
8 | tsconfig: 'tsconfig.build.json',
9 | outfile: 'dist/index.js',
10 | })
11 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Google Analytics 4",
3 | "description": "Google Analytics 4 Managed Component",
4 | "namespace": "google-analytics-4",
5 | "icon": "assets/google-analytics-4.svg",
6 | "categories": ["Analytics"],
7 | "provides": ["events"],
8 | "allowCustomFields": true,
9 | "permissions": {
10 | "client_network_requests": {
11 | "description": "Google Analytics uses client fetch to attribute sessions more accurately",
12 | "required": false
13 | },
14 | "execute_unsafe_scripts": {
15 | "description": "If you're using GA Audiences",
16 | "required": false
17 | },
18 | "access_client_kv": {
19 | "description": "",
20 | "required": true
21 | },
22 | "server_network_requests": {
23 | "description": "Google Analytics uses serverside network requests to report data",
24 | "required": true
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@managed-components/google-analytics-4",
3 | "version": "1.1.4",
4 | "description": "",
5 | "main": "dist/index.js",
6 | "scripts": {
7 | "lint": "eslint --ext .ts,.js, src",
8 | "lint:fix": "eslint --ext .ts,.js, src --fix",
9 | "bundle": "node esbuild.js",
10 | "build": "npm run lint && npm run typecheck && npm run bundle",
11 | "typecheck": "tsc --project tsconfig.build.json --noEmit",
12 | "test": "vitest run --globals --passWithNoTests",
13 | "test:dev": "vitest --globals",
14 | "release": "npm run build && npm version patch && npm publish"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/managed-components/google-analytics-4.git"
19 | },
20 | "keywords": [
21 | "webcm",
22 | "managed-components",
23 | "google-analytics-4"
24 | ],
25 | "author": "Cloudflare Managed Components Team (https://blog.cloudflare.com/zaraz-open-source-managed-components-and-webcm/)",
26 | "license": "Apache-2.0",
27 | "bugs": {
28 | "url": "https://github.com/managed-components/google-analytics-4/issues"
29 | },
30 | "homepage": "https://github.com/managed-components/google-analytics-4#readme",
31 | "devDependencies": {
32 | "@managed-components/types": "^1.3.1",
33 | "@typescript-eslint/eslint-plugin": "^5.27.0",
34 | "all-contributors-cli": "^6.20.0",
35 | "esbuild": "^0.14.42",
36 | "eslint": "^8.16.0",
37 | "eslint-config-prettier": "^8.5.0",
38 | "eslint-plugin-prettier": "^4.0.0",
39 | "ts-node": "^10.8.0",
40 | "typescript": "^4.7.2",
41 | "vitest": "^0.13.0"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/ecommerce.ts:
--------------------------------------------------------------------------------
1 | const EVENTS: { [k: string]: string } = {
2 | 'Product Added': 'add_to_cart',
3 | 'Product Added to Wishlist': 'add_to_wishlist',
4 | 'Product Removed': 'remove_from_cart',
5 | 'Product Clicked': 'select_item',
6 | 'Product Viewed': 'view_item',
7 | 'Cart Viewed': 'view_cart',
8 | 'Product List Viewed': 'view_item_list',
9 | 'Products Searched': 'view_search_results',
10 | 'Clicked Promotion': 'select_promotion',
11 | 'Viewed Promotion': 'view_promotion',
12 | 'Checkout Started': 'begin_checkout',
13 | 'Checkout Step Completed': 'checkout_progress',
14 | 'Payment Info Entered': 'add_payment_info',
15 | 'Order Completed': 'purchase',
16 | 'Order Refunded': 'refund',
17 | 'Shipping Info Entered': 'add_shipping_info',
18 | }
19 |
20 | const PRODUCT_DETAILS: string[] = [
21 | 'cart_id',
22 | 'product_id',
23 | 'sku',
24 | 'category',
25 | 'name',
26 | 'brand',
27 | 'variant',
28 | 'price',
29 | 'quantity',
30 | 'coupon',
31 | 'position',
32 | 'affiliation',
33 | 'discount',
34 | 'currency',
35 | ]
36 |
37 | // list of params that will be prefixed in the request with
38 | // ep for string values
39 | // epn for numbers
40 | const PREFIX_PARAMS_MAPPING: { [k: string]: string } = {
41 | checkout_id: 'transaction_id',
42 | order_id: 'transaction_id', // used in refund
43 | // currency: 'currency', // currency is added by hand, not prefixed
44 | // the last in the list has priority - total will overwrite price for example
45 | price: 'value',
46 | value: 'value',
47 | total: 'value',
48 | shipping: 'shipping',
49 | tax: 'tax',
50 | coupon: 'coupon',
51 | payment_type: 'payment_type',
52 | list_id: 'item_list_id',
53 | category: 'item_list_name',
54 | query: 'search_term',
55 | affiliation: 'affiliation',
56 | // promotions
57 | promotion_id: 'promotion_id',
58 | name: 'promotion_name',
59 | creative: 'creative_name',
60 | position: 'location_id',
61 | payment_method: 'payment_type',
62 | }
63 |
64 | // ga4 ecommerce mappings
65 | const PRODUCT_DETAILS_MAPPING: { [k: string]: string } = {
66 | product_id: 'id',
67 | sku: 'id',
68 | name: 'nm',
69 | brand: 'br',
70 | category: 'ca',
71 | variant: 'va',
72 | price: 'pr',
73 | quantity: 'qt',
74 | coupon: 'cp',
75 | }
76 | const _listMapping: { [k: string]: string } = {
77 | id: 'id',
78 | name: 'nm',
79 | brand: 'br',
80 | variant: 'va',
81 | list_name: 'ln',
82 | list_position: 'lp',
83 | list: 'ln',
84 | position: 'lp',
85 | creative: 'cn',
86 | }
87 |
88 | const _prepareStringContent = function (value: unknown) {
89 | value = String(value)
90 | return ('' + value).replace(/~/g, function () {
91 | return '~~'
92 | })
93 | }
94 |
95 | // takes a GA4 item and turns it into a query parameter
96 | // eg: id45790-32~caGames~nmMonopoly: 3rd Edition~pr19~qt1
97 | const buildProductRequest = (item: { [k: string]: unknown }) => {
98 | const allKeys = {}
99 | for (const [id, value] of Object.entries(item)) {
100 | const result: { [k: string]: string } = {}
101 | const preppedValue = _prepareStringContent(value)
102 | Object.prototype.hasOwnProperty.call(PRODUCT_DETAILS_MAPPING, id) &&
103 | (result[PRODUCT_DETAILS_MAPPING[id]] = preppedValue)
104 | if (Object.prototype.hasOwnProperty.call(_listMapping, id)) {
105 | if (!Object.prototype.hasOwnProperty.call(result, _listMapping[id])) {
106 | result[_listMapping[id]] = preppedValue
107 | }
108 | }
109 | Object.assign(allKeys, result)
110 | }
111 |
112 | const resultList = []
113 | for (const [key, val] of Object.entries(allKeys)) {
114 | resultList.push('' + key + val)
115 | }
116 |
117 | return resultList.join('~')
118 | }
119 |
120 | // product comes in standard format
121 | // returns GA4's standard item
122 | const mapProductToItem = (product: Record) => {
123 | const eventProductDescription = PRODUCT_DETAILS
124 | const item: Record = {}
125 | for (const prop of eventProductDescription) {
126 | product[prop] && (item[prop] = product[prop])
127 | }
128 | return item
129 | }
130 |
131 | export { EVENTS, mapProductToItem, PREFIX_PARAMS_MAPPING, buildProductRequest }
132 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { ComponentSettings, Manager, MCEvent } from '@managed-components/types'
2 | import { getFinalURL } from './requestBuilder'
3 | import { countConversion, countPageview } from './utils'
4 |
5 | const SESSION_DURATION_IN_MIN = 30
6 |
7 | const sendGaAudiences = (
8 | event: MCEvent,
9 | settings: ComponentSettings,
10 | requestBody: Record
11 | ) => {
12 | const { client } = event
13 |
14 | const baseDoubleClick = 'https://stats.g.doubleclick.net/g/collect?'
15 | const cid = requestBody['cid']
16 | if (!cid || typeof cid != 'string') {
17 | throw new Error('cid in requestBody should be a string')
18 | }
19 | const doubleClick: Record = {
20 | t: 'dc',
21 | aip: '1',
22 | _r: '3',
23 | v: '1',
24 | _v: 'j86',
25 | tid: settings.tid,
26 | cid,
27 | _u: 'KGDAAEADQAAAAC~',
28 | z: (+Math.floor(2147483647 * Math.random())).toString(),
29 | }
30 | const doubleClickParams = new URLSearchParams(doubleClick).toString()
31 | const finalDoubleClickURL = baseDoubleClick + doubleClickParams
32 |
33 | if (
34 | (settings['ga-audiences'] || event.payload['ga-audiences']) &&
35 | (!client.get('_z_ga_audiences') ||
36 | client.get('_z_ga_audiences') !== requestBody['cid'])
37 | ) {
38 | // Build the GAv4 Audiences request
39 | const audiences = {
40 | ...doubleClick,
41 | t: 'sr',
42 | _r: '4',
43 | slf_rd: '1',
44 | }
45 | const audienceParams = new URLSearchParams(audiences).toString()
46 | const baseAudienceURL = 'https://www.google.com/ads/ga-audiences?'
47 | const finalAudienceURL = baseAudienceURL + audienceParams
48 | let clientJSAudience = ''
49 | // Call GAv4-Audiences on Google.com
50 | client.fetch(finalAudienceURL)
51 | client.set('_z_ga_audiences', cid, {
52 | scope: 'infinite',
53 | })
54 | // Trigger the DoubleClick with XHR because we need the response text - it holds the local Google domain
55 | clientJSAudience += `x=new XMLHttpRequest,x.withCredentials=!0,x.open("POST","${finalDoubleClickURL}",!0),x.onreadystatechange=function(){`
56 | clientJSAudience += `if (4 == x.readyState) {`
57 | clientJSAudience += `const domain = x.responseText.trim();`
58 | clientJSAudience += `if (domain.startsWith("1g") && domain.length > 2) {`
59 | // Trigger the request to the local Google domain too
60 | clientJSAudience += `fetch("${finalAudienceURL}".replace("www.google.com", "www.google."+domain.slice(2)));`
61 | clientJSAudience += `}}`
62 | clientJSAudience += `},x.send();`
63 | client.execute(clientJSAudience)
64 | } else {
65 | // If no GAv4-Audiences, just trigger DoubleClick normally
66 | client.fetch(finalDoubleClickURL)
67 | }
68 | }
69 |
70 | export default async function (manager: Manager, settings: ComponentSettings) {
71 | const sendEvent = async (
72 | eventType: string,
73 | event: MCEvent,
74 | settings: ComponentSettings
75 | ) => {
76 | const { client } = event
77 | const { finalURL, requestBody } = getFinalURL(eventType, event, settings)
78 |
79 | manager.fetch(finalURL, {
80 | headers: { 'User-Agent': client.userAgent },
81 | })
82 |
83 | if (settings['ga-audiences'] || event.payload['ga-audiences']) {
84 | sendGaAudiences(event, settings, requestBody)
85 | }
86 |
87 | client.set('let', Date.now().toString()) // reset the last event time
88 | }
89 |
90 | const onVisibilityChange =
91 | (settings: ComponentSettings) => (event: MCEvent) => {
92 | const { client, payload } = event
93 |
94 | if (payload.visibilityChange[0].state == 'visible') {
95 | event.client.set(
96 | 'engagementStart',
97 | payload.visibilityChange[0].timestamp
98 | )
99 | } else if (payload.visibilityChange[0].state == 'hidden') {
100 | // on pageblur
101 | computeEngagementDuration(event)
102 |
103 | const msSinceLastEvent = Date.now() - parseInt(client.get('let') || '0') // _let = "_lastEventTime"
104 | if (msSinceLastEvent > 10000) {
105 | // order matters so engagement duration is set before dispatching the hit
106 | computeEngagementDuration(event)
107 |
108 | sendEvent('user_engagement', event, settings)
109 |
110 | // Reset engagementDuration after event has been dispatched so it does not accumulate
111 | event.client.set('engagementDuration', '0')
112 | }
113 | }
114 | }
115 |
116 | const computeEngagementDuration = (event: MCEvent) => {
117 | const now = new Date(Date.now()).getTime()
118 |
119 | let engagementDuration =
120 | parseInt(event.client.get('engagementDuration') || '0') || 0
121 | let engagementStart =
122 | parseInt(event.client.get('engagementStart') || '0') || now
123 | const delaySinceLast = (now - engagementStart) / 1000 / 60
124 |
125 | // Last interaction occured in a previous session, reset engagementStart
126 | if (delaySinceLast > SESSION_DURATION_IN_MIN) {
127 | engagementStart = now
128 | }
129 |
130 | engagementDuration += now - engagementStart
131 |
132 | event.client.set('engagementDuration', `${engagementDuration}`)
133 |
134 | // engagement start gets reset on every new pageview or event
135 | event.client.set('engagementStart', `${now}`)
136 | }
137 |
138 | manager.createEventListener('visibilityChange', onVisibilityChange(settings))
139 |
140 | manager.addEventListener('event', event => {
141 | // count conversion events for 'seg' value
142 | countConversion(event)
143 | // order matters so engagement duration is set before dispatching the hit
144 | computeEngagementDuration(event)
145 |
146 | sendEvent('event', event, settings)
147 |
148 | // Reset engagementDuration after event has been dispatched so it does not accumulate
149 | event.client.set('engagementDuration', '0')
150 | })
151 |
152 | manager.addEventListener('pageview', event => {
153 | event.client.attachEvent('visibilityChange')
154 |
155 | // count pageviews for 'seg' value
156 | countPageview(event.client)
157 | // order matters so engagement duration is set before dispatching the hit
158 |
159 | computeEngagementDuration(event)
160 |
161 | sendEvent('page_view', event, settings)
162 |
163 | // Reset engagementDuration after event has been dispatched so it does not accumulate
164 | event.client.set('engagementDuration', '0')
165 | })
166 |
167 | manager.addEventListener('ecommerce', async event => {
168 | event.payload.conversion = true // set ecommerce events as conversion events
169 | // count conversion events for 'seg' value
170 | countConversion(event)
171 | // order matters so engagement duration is set before dispatching the hit
172 | computeEngagementDuration(event)
173 |
174 | sendEvent('ecommerce', event, settings)
175 |
176 | // Reset engagementDuration after event has been dispatched so it does not accumulate
177 | event.client.set('engagementDuration', '0')
178 | })
179 | }
180 |
--------------------------------------------------------------------------------
/src/requestBuilder.ts:
--------------------------------------------------------------------------------
1 | import { ComponentSettings, MCEvent } from '@managed-components/types'
2 | import {
3 | buildProductRequest,
4 | EVENTS,
5 | mapProductToItem,
6 | PREFIX_PARAMS_MAPPING,
7 | } from './ecommerce'
8 | import { flattenKeys, getParamSafely } from './utils'
9 |
10 | const getRandomInt = () => Math.floor(2147483647 * Math.random())
11 |
12 | function getToolRequest(
13 | eventType: string,
14 | event: MCEvent,
15 | settings: ComponentSettings
16 | ): Record {
17 | let payload: MCEvent['payload'] = {}
18 |
19 | // avoid sending ecommerce flattened products list to GA4
20 | const { client, payload: fullPayload } = event
21 | if (eventType === 'ecommerce') {
22 | const ecommercePayload: Record = {}
23 | for (const key of Object.keys(fullPayload.ecommerce)) {
24 | if (
25 | key !== 'products' &&
26 | key !== 'currency' &&
27 | !PREFIX_PARAMS_MAPPING[key]
28 | ) {
29 | ecommercePayload[key] = fullPayload.ecommerce[key]
30 | }
31 | }
32 | if (fullPayload.gcd) ecommercePayload.gcd = fullPayload.gcd
33 | payload = ecommercePayload
34 | } else {
35 | payload = fullPayload
36 | }
37 |
38 | let eventsCounter = parseInt(client.get('counter') || '')
39 | if (!Number.isInteger(eventsCounter)) eventsCounter = 0
40 | eventsCounter++
41 | client.set('counter', eventsCounter.toString())
42 |
43 | const requestBody: Record = {
44 | v: 2,
45 | // gtm: '2oe5j0', // TODO: GTM version hash? not clear if we need this
46 | tid: settings.tid,
47 | sr: client.screenWidth + 'x' + client.screenHeight,
48 | ul: client.language,
49 | ...getParamSafely('dt', [payload.dt, client.title]),
50 | _s: eventsCounter,
51 | ...(!(payload.hideOriginalIP || settings.hideOriginalIP) && {
52 | _uip: client.ip,
53 | }),
54 | ...getParamSafely('dr', [payload.dr, client.referer]),
55 | ...getParamSafely('dl', [payload.dl, client.url.href]),
56 | ...(payload.ir && { ir: true }),
57 | ...(payload.dbg && { dbg: true }),
58 | }
59 |
60 | // Session counting
61 | let sessionCounter = parseInt(client.get('session_counter') || '')
62 | if (!Number.isInteger(sessionCounter)) {
63 | sessionCounter = 0
64 | }
65 | if (client) {
66 | // Determine if the session is engaged to set the 'seg' value
67 | const pageviewCounter = parseInt(client.get('pageviewCounter') || '0')
68 | const conversionCounter = parseInt(client.get('conversionCounter') || '0')
69 | const engagementDuration = parseInt(client.get('engagementDuration') || '0')
70 |
71 | // Session will be marked engaged if longer than 10 seconds, has at least 1 conversion event, and 2 or more pageviews
72 | if (
73 | engagementDuration >= 10 &&
74 | conversionCounter > 0 &&
75 | pageviewCounter > 1
76 | ) {
77 | requestBody['seg'] = 1 // Session engaged
78 | } else {
79 | requestBody['seg'] = 0
80 | }
81 | }
82 |
83 | // Create, refresh or renew session id
84 | const sessionLength = 30 * 60 * 1000 // By default, GA4 keeps sessions for 30 minutes
85 | let currentSessionID = client.get('ga4sid')
86 | if (!currentSessionID) {
87 | requestBody['_ss'] = 1 // Session start
88 | sessionCounter++
89 | currentSessionID = getRandomInt().toString()
90 | }
91 | client.set('ga4sid', currentSessionID, { expiry: sessionLength })
92 | requestBody['sid'] = currentSessionID
93 | requestBody['_p'] = currentSessionID
94 |
95 | client.set('session_counter', sessionCounter.toString(), {
96 | scope: 'infinite',
97 | })
98 | requestBody['sct'] = sessionCounter
99 |
100 | // Handle Client ID
101 | let cid = client.get('ga4')?.split('.').slice(-2).join('.')
102 | if (!cid) {
103 | cid = crypto.randomUUID()
104 | requestBody['_fv'] = 1 // No Client ID -> setting "First Visit"
105 | }
106 | client.set('ga4', cid, { scope: 'infinite' })
107 | requestBody['cid'] = payload.cid || cid
108 |
109 | //const notTheFirstSession = parseInt(requestBody['_s'] as string) > 1
110 | const engagementDuration =
111 | parseInt(String(client.get('engagementDuration')), 10) || 0
112 | if (engagementDuration) {
113 | requestBody._et = engagementDuration
114 | }
115 |
116 | /* Start of gclid treating */
117 | if (client.url.searchParams?.get('gl')) {
118 | try {
119 | const _gl = client.url.searchParams?.get('gl') as string
120 | const gclaw = atob(_gl.split('*').pop()?.replaceAll('.', '') || '')
121 | client.set('gclaw', gclaw, { scope: 'infinite' })
122 | requestBody.gclid = gclaw.split('.').pop()
123 | } catch (e) {
124 | console.log('Google Analytics: Error parsing gclaw', e)
125 | }
126 | }
127 | if (client.get('gcl_aw')) {
128 | requestBody.gclid = client.get('gcl_aw')?.split('.').pop()
129 | }
130 | if (client.get('gclid')) {
131 | requestBody.gclid = client.get('gclid')
132 | }
133 | /* End of gclid treating */
134 |
135 | if (requestBody.gclid) {
136 | const url = new URL(requestBody.dl as string)
137 | url.searchParams.get('gclid') ||
138 | url.searchParams.append('gclid', requestBody.gclid as string)
139 | requestBody.dl = url
140 | }
141 |
142 | Object.entries({
143 | utma: '_utma',
144 | utmz: '_utmz',
145 | dpd: '_dpd',
146 | utm_wtk: 'utm_wtk',
147 | }).forEach(([searchParam, cookieName]) => {
148 | if (client.url.searchParams.get(searchParam)) {
149 | client.set(cookieName, client.url.searchParams.get(searchParam), {
150 | scope: 'infinite',
151 | })
152 | }
153 | })
154 |
155 | // Don't append ep/epn to these keys
156 | const builtInKeys = [
157 | 'tid',
158 | 'uid',
159 | 'en',
160 | 'ni',
161 | 'conversion',
162 | 'dr',
163 | 'dl',
164 | 'ir',
165 | 'dbg',
166 | 'gcs',
167 | 'gcd',
168 | 'cid',
169 | 'dt',
170 | ]
171 | const eventData = flattenKeys(payload)
172 |
173 | // Remove setting keys from the payload that is sent to GA4
174 | delete eventData['hideOriginalIP']
175 | delete eventData['ga-audiences']
176 | // `up.X`s are User Properties and should stay with this prefix
177 | // Otherwise, it's an Event Property. If numerical - prefixed with `epn.`,
178 | // and if a string, it's just `ep.`
179 | for (const key in eventData) {
180 | if (!builtInKeys.includes(key) && !key.startsWith('up.')) {
181 | if (Number(eventData[key])) eventData['epn.' + key] = eventData[key]
182 | else eventData['ep.' + key] = eventData[key]
183 | delete eventData[key]
184 | }
185 | }
186 |
187 | if (eventData.conversion) {
188 | eventData._c = 1
189 | }
190 | delete eventData.conversion
191 |
192 | const toolRequest = { ...requestBody, ...eventData }
193 | return toolRequest
194 | }
195 |
196 | const getFinalURL = (
197 | eventType: string,
198 | event: MCEvent,
199 | settings: ComponentSettings
200 | ) => {
201 | const { payload } = event
202 | let toolRequest: Record = {}
203 | // toolRequest['ep.debug_mode'] = true
204 |
205 | toolRequest.en = payload.en || eventType
206 |
207 | // ecommerce events
208 | if (eventType === 'ecommerce') {
209 | const ecommerceData = payload.ecommerce
210 | let prQueryParams
211 |
212 | // event name and currency will always be added as non prefixed query params
213 | const eventName = event.name || ''
214 | toolRequest.en = EVENTS[eventName] || eventName
215 | ecommerceData.currency && (toolRequest.cu = ecommerceData.currency)
216 |
217 | for (const key of Object.keys(PREFIX_PARAMS_MAPPING)) {
218 | const param = PREFIX_PARAMS_MAPPING[key]
219 | const prefix = Number(ecommerceData[key]) ? 'epn' : 'ep'
220 | ecommerceData[key] &&
221 | (toolRequest[`${prefix}.${param}`] = ecommerceData[key])
222 | }
223 |
224 | if (ecommerceData.products) {
225 | // handle products list
226 | for (const [index, product] of (ecommerceData.products || []).entries()) {
227 | const item = mapProductToItem(product)
228 | prQueryParams = buildProductRequest(item)
229 | toolRequest[`pr${index + 1}`] = prQueryParams
230 | }
231 | } else {
232 | // handle single product data
233 | const item = mapProductToItem(ecommerceData)
234 | prQueryParams = buildProductRequest(item)
235 | if (prQueryParams) toolRequest['pr1'] = prQueryParams
236 | }
237 | }
238 |
239 | const partialToolRequest = getToolRequest(eventType, event, settings)
240 |
241 | toolRequest = {
242 | ...toolRequest,
243 | ...(partialToolRequest as unknown as Record),
244 | }
245 |
246 | // Presence of `debug_mode` key will still enable debug mode.
247 | // Removing the key allows conditionally disabling debug_mode.
248 | if (
249 | !toolRequest['ep.debug_mode'] ||
250 | toolRequest['ep.debug_mode'] === 'false'
251 | ) {
252 | delete toolRequest['ep.debug_mode']
253 | }
254 |
255 | const queryParams = new URLSearchParams(toolRequest).toString()
256 |
257 | const baseURL = 'https://www.google-analytics.com/g/collect?'
258 | const finalURL = baseURL + queryParams
259 |
260 | return { finalURL, requestBody: toolRequest }
261 | }
262 |
263 | export { getToolRequest, getFinalURL }
264 |
--------------------------------------------------------------------------------
/src/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { getParamSafely } from './utils'
2 |
3 | describe('getParamSafely works', () => {
4 | const relatedValue = 'abcd'
5 | const relatedObject: Record = {
6 | href: 'https://example.com',
7 | }
8 | // Test 1
9 | it('With two params, first param missing, return 2nd param', () => {
10 | const result = getParamSafely('someKey', [
11 | relatedObject?.nonExistantKey,
12 | relatedValue,
13 | ])
14 | expect({ ...result }).not.toEqual({})
15 | expect({ ...result }).toEqual({ someKey: 'abcd' })
16 | })
17 |
18 | // // Test 2
19 | it('With two params, first param hit', () => {
20 | const result = getParamSafely('someKey', [
21 | relatedObject?.href,
22 | relatedValue,
23 | ])
24 | expect({ ...result }).not.toEqual({})
25 | expect({ ...result }).toEqual({ someKey: 'https://example.com' })
26 | })
27 |
28 | // Test 3
29 | it('With two params, both param missing', () => {
30 | const result = getParamSafely('someKey', [
31 | relatedObject?.something,
32 | relatedObject.xyz,
33 | ])
34 | expect({ ...result }).toEqual({})
35 | })
36 | })
37 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { Client, MCEvent } from '@managed-components/types'
2 |
3 | export const flattenKeys = (obj: { [k: string]: unknown } = {}, prefix = '') =>
4 | Object.keys(obj).reduce((acc: { [k: string]: unknown }, k) => {
5 | const pre = prefix.length ? `${prefix}.` : ''
6 | const value = obj[k]
7 | if (
8 | typeof value === 'object' &&
9 | !Array.isArray(obj[k]) &&
10 | value !== null &&
11 | Object.keys(value).length > 0
12 | ) {
13 | Object.assign(acc, flattenKeys(value as Record, pre + k))
14 | } else if (Array.isArray(value) && value !== null) {
15 | value.forEach((v: unknown, i: number) => {
16 | if (typeof v === 'object' && v !== null) {
17 | Object.assign(
18 | acc,
19 | flattenKeys(v as Record, pre + k + '.' + i)
20 | )
21 | } else {
22 | acc[pre + k + '.' + i] = v
23 | }
24 | })
25 | } else {
26 | acc[pre + k] = value
27 | }
28 | return acc
29 | }, {})
30 |
31 | /**
32 | * @param paramKey - The key that needs to be merged into original object
33 | * @param paramValuesToUse - fallback values that `getParamSafely` will try and retrieve
34 | * @returns object - The return value of getParamSafely must be spread to merge into another object
35 | * @todo add test
36 | */
37 | export const getParamSafely = (
38 | paramKey: string,
39 | paramValuesToUse: Array
40 | ) => {
41 | for (const param of paramValuesToUse) {
42 | if (param) {
43 | return { [paramKey]: param }
44 | }
45 | }
46 | return {}
47 | }
48 | // pageviews in session counter
49 | export const countPageview = (client: Client) => {
50 | let pageviewCounter = parseInt(client.get('pageviewCounter') || '0') || 0
51 |
52 | if (pageviewCounter === 0) {
53 | client.set('pageviewCounter', '1', { scope: 'session' })
54 | } else {
55 | pageviewCounter++
56 | client.set('pageviewCounter', `${pageviewCounter}`, { scope: 'session' })
57 | }
58 | }
59 |
60 | // conversion events in session counter
61 | export const countConversion = (event: MCEvent) => {
62 | const { client } = event
63 | let conversionCounter = parseInt(client.get('conversionCounter') || '0') || 0
64 | if (conversionCounter === 0 && event.payload.conversion) {
65 | client.set('conversionCounter', '1', { scope: 'session' })
66 | } else {
67 | conversionCounter++
68 | client.set('conversionCounter', `${conversionCounter}`, {
69 | scope: 'session',
70 | })
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["src/**/*.test.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Enable incremental compilation */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25 |
26 | /* Modules */
27 | "module": "commonjs" /* Specify what module code is generated. */,
28 | // "rootDir": "./", /* Specify the root folder within your source files. */
29 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
34 | "types": [
35 | "vitest/globals"
36 | ] /* Specify type package names to be included without being referenced in a source file. */,
37 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
38 | "resolveJsonModule": true /* Enable importing .json files */,
39 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */
40 |
41 | /* JavaScript Support */
42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
45 |
46 | /* Emit */
47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
52 | "outDir": "./dist" /* Specify an output folder for all emitted files. */,
53 | // "removeComments": true, /* Disable emitting comments. */
54 | // "noEmit": true, /* Disable emitting files from a compilation. */
55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
63 | // "newLine": "crlf", /* Set the newline character for emitting files. */
64 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
67 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
69 |
70 | /* Interop Constraints */
71 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
72 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
73 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
74 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
75 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
76 |
77 | /* Type Checking */
78 | "strict": true /* Enable all strict type-checking options. */,
79 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
80 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
81 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
82 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
83 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
84 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
85 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
86 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
87 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
88 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
89 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
90 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
91 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
92 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
93 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
94 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
95 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
96 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
97 |
98 | /* Completeness */
99 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
100 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
101 | }
102 | }
103 |
--------------------------------------------------------------------------------