diff --git a/package-lock.json b/package-lock.json index e20a47365..2cfb15c5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5957,67 +5957,15 @@ } }, "@jest/types": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-25.1.0.tgz", - "integrity": "sha512-VpOtt7tCrgvamWZh1reVsGADujKigBUFTi19mlRjqEGsE8qH4r3s+skY33dNdXOwyZIvuftZ5tqdF1IgsMejMA==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz", + "integrity": "sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw==", "dev": true, "requires": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^1.1.1", "@types/yargs": "^15.0.0", "chalk": "^3.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", - "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "dev": true, - "requires": { - "@types/color-name": "^1.1.1", - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", - "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "@loadable/component": { @@ -6882,6 +6830,29 @@ "integrity": "sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q==", "dev": true }, + "@rudderstack/rudder-sdk-node": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@rudderstack/rudder-sdk-node/-/rudder-sdk-node-0.0.2.tgz", + "integrity": "sha512-KkqQwV8+/YH5AL6vKCuUvUv7o2+c6tYJ6G7b7iJ+bojTL62Hyi1XAe2rCSrKjNgTFSeXkRaHLeCKSGYuJCPy8w==", + "requires": { + "@segment/loosely-validate-event": "^2.0.0", + "auto-changelog": "^1.16.2", + "axios": "^0.19.0", + "axios-retry": "^3.0.2", + "lodash.isstring": "^4.0.1", + "md5": "^2.2.1", + "ms": "^2.0.0", + "remove-trailing-slash": "^0.1.0", + "uuid": "^3.2.1" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, "@samverschueren/stream-to-observable": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz", @@ -6891,6 +6862,15 @@ "any-observable": "^0.3.0" } }, + "@segment/loosely-validate-event": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz", + "integrity": "sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==", + "requires": { + "component-type": "^1.2.1", + "join-component": "^1.1.0" + } + }, "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", @@ -10005,6 +9985,28 @@ "dev": true, "optional": true }, + "auto-changelog": { + "version": "1.16.4", + "resolved": "https://registry.npmjs.org/auto-changelog/-/auto-changelog-1.16.4.tgz", + "integrity": "sha512-h7diyELoq692AA4oqO50ULoYKIomUdzuQ+NW+eFPwIX0xzVbXEu9cIcgzZ3TYNVbpkGtcNKh51aRfAQNef7HVA==", + "requires": { + "commander": "^5.0.0", + "core-js": "^3.6.4", + "handlebars": "^4.7.3", + "lodash.uniqby": "^4.7.0", + "node-fetch": "^2.6.0", + "parse-github-url": "^1.0.2", + "regenerator-runtime": "^0.13.5", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, "autoprefixer": { "version": "9.7.5", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.7.5.tgz", @@ -10168,7 +10170,6 @@ "version": "0.19.2", "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", - "dev": true, "requires": { "follow-redirects": "1.5.10" }, @@ -10177,7 +10178,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, "requires": { "ms": "2.0.0" } @@ -10186,7 +10186,6 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", - "dev": true, "requires": { "debug": "=3.1.0" } @@ -10194,11 +10193,18 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" } } }, + "axios-retry": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.1.8.tgz", + "integrity": "sha512-yPw5Y4Bg6Dgmhm35KaJFtlh23s1TecW0HsUerK4/IS1UKl0gtN2aJqdEKtVomiOS/bDo5w4P3sqgki/M10eF8Q==", + "requires": { + "is-retry-allowed": "^1.1.0" + } + }, "axobject-query": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.0.2.tgz", @@ -12805,8 +12811,7 @@ "charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=", - "dev": true + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" }, "check-types": { "version": "8.0.3", @@ -13482,8 +13487,7 @@ "commander": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.0.0.tgz", - "integrity": "sha512-JrDGPAKjMGSP1G0DUoaceEJ3DZgAfr/q6X7FVk4+U5KxUSKviYGM2k6zWkfyyBHy5rAtzgYJFa1ro2O9PtoxwQ==", - "dev": true + "integrity": "sha512-JrDGPAKjMGSP1G0DUoaceEJ3DZgAfr/q6X7FVk4+U5KxUSKviYGM2k6zWkfyyBHy5rAtzgYJFa1ro2O9PtoxwQ==" }, "comment-json": { "version": "3.0.2", @@ -13563,6 +13567,11 @@ "integrity": "sha1-+bffm5kntubZfJvScqqGdnDzSUQ=", "dev": true }, + "component-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-type/-/component-type-1.2.1.tgz", + "integrity": "sha1-ikeQFwAjjk/DIml3EjAibyS0Fak=" + }, "component-xor": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/component-xor/-/component-xor-0.0.4.tgz", @@ -13597,9 +13606,9 @@ }, "dependencies": { "mime-db": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", - "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==", "dev": true } } @@ -14544,8 +14553,7 @@ "crypt": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=", - "dev": true + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" }, "crypto-browserify": { "version": "3.12.0", @@ -21376,28 +21384,28 @@ "dependencies": { "abbrev": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "resolved": false, "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true, "optional": true }, "ansi-regex": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "resolved": false, "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true, "optional": true }, "aproba": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "resolved": false, "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true, "optional": true }, "are-we-there-yet": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "resolved": false, "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", "dev": true, "optional": true, @@ -21408,14 +21416,14 @@ }, "balanced-match": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "resolved": false, "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true, "optional": true }, "brace-expansion": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "resolved": false, "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "optional": true, @@ -21426,42 +21434,42 @@ }, "chownr": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", + "resolved": false, "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==", "dev": true, "optional": true }, "code-point-at": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "resolved": false, "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true, "optional": true }, "concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "resolved": false, "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true, "optional": true }, "console-control-strings": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "resolved": false, "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true, "optional": true }, "core-util-is": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "resolved": false, "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true, "optional": true }, "debug": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "resolved": false, "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "dev": true, "optional": true, @@ -21471,28 +21479,28 @@ }, "deep-extend": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "resolved": false, "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "optional": true }, "delegates": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "resolved": false, "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true, "optional": true }, "detect-libc": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "resolved": false, "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "dev": true, "optional": true }, "fs-minipass": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", + "resolved": false, "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", "dev": true, "optional": true, @@ -21502,14 +21510,14 @@ }, "fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "resolved": false, "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true, "optional": true }, "gauge": { "version": "2.7.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "resolved": false, "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "dev": true, "optional": true, @@ -21526,7 +21534,7 @@ }, "glob": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "resolved": false, "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "dev": true, "optional": true, @@ -21541,14 +21549,14 @@ }, "has-unicode": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "resolved": false, "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true, "optional": true }, "iconv-lite": { "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "resolved": false, "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "optional": true, @@ -21558,7 +21566,7 @@ }, "ignore-walk": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz", + "resolved": false, "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", "dev": true, "optional": true, @@ -21568,7 +21576,7 @@ }, "inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "resolved": false, "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "optional": true, @@ -21579,21 +21587,21 @@ }, "inherits": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "resolved": false, "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true, "optional": true }, "ini": { "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "resolved": false, "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true, "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "resolved": false, "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, "optional": true, @@ -21603,14 +21611,14 @@ }, "isarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "resolved": false, "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true, "optional": true }, "minimatch": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "resolved": false, "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, "optional": true, @@ -21620,14 +21628,14 @@ }, "minimist": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": false, "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true, "optional": true }, "minipass": { "version": "2.3.5", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", + "resolved": false, "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", "dev": true, "optional": true, @@ -21638,7 +21646,7 @@ }, "minizlib": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz", + "resolved": false, "integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==", "dev": true, "optional": true, @@ -21648,7 +21656,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": false, "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "optional": true, @@ -21658,7 +21666,7 @@ }, "ms": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "resolved": false, "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "dev": true, "optional": true @@ -21672,7 +21680,7 @@ }, "needle": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/needle/-/needle-2.3.0.tgz", + "resolved": false, "integrity": "sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg==", "dev": true, "optional": true, @@ -21684,7 +21692,7 @@ }, "node-pre-gyp": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz", + "resolved": false, "integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==", "dev": true, "optional": true, @@ -21703,7 +21711,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "resolved": false, "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, @@ -21714,14 +21722,14 @@ }, "npm-bundled": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.6.tgz", + "resolved": false, "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==", "dev": true, "optional": true }, "npm-packlist": { "version": "1.4.1", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.1.tgz", + "resolved": false, "integrity": "sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==", "dev": true, "optional": true, @@ -21732,7 +21740,7 @@ }, "npmlog": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "resolved": false, "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "dev": true, "optional": true, @@ -21745,21 +21753,21 @@ }, "number-is-nan": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "resolved": false, "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true, "optional": true }, "object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "resolved": false, "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true, "optional": true }, "once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "resolved": false, "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "optional": true, @@ -21769,21 +21777,21 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "resolved": false, "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true, "optional": true }, "os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": false, "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true, "optional": true }, "osenv": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "resolved": false, "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "dev": true, "optional": true, @@ -21794,21 +21802,21 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": false, "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true, "optional": true }, "process-nextick-args": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "resolved": false, "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", "dev": true, "optional": true }, "rc": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "resolved": false, "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, "optional": true, @@ -21821,7 +21829,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": false, "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true, "optional": true @@ -21830,7 +21838,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": false, "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "optional": true, @@ -21846,7 +21854,7 @@ }, "rimraf": { "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "resolved": false, "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "dev": true, "optional": true, @@ -21856,49 +21864,49 @@ }, "safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "resolved": false, "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "optional": true }, "safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "resolved": false, "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "optional": true }, "sax": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "resolved": false, "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true, "optional": true }, "semver": { "version": "5.7.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "resolved": false, "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", "dev": true, "optional": true }, "set-blocking": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "resolved": false, "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true, "optional": true }, "signal-exit": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "resolved": false, "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true, "optional": true }, "string-width": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "resolved": false, "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, "optional": true, @@ -21910,7 +21918,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "resolved": false, "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "optional": true, @@ -21920,7 +21928,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": false, "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "optional": true, @@ -21930,14 +21938,14 @@ }, "strip-json-comments": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "resolved": false, "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true, "optional": true }, "tar": { "version": "4.4.8", - "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.8.tgz", + "resolved": false, "integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==", "dev": true, "optional": true, @@ -21953,14 +21961,14 @@ }, "util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "resolved": false, "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true, "optional": true }, "wide-align": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "resolved": false, "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", "dev": true, "optional": true, @@ -21970,14 +21978,14 @@ }, "wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "resolved": false, "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true, "optional": true }, "yallist": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "resolved": false, "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", "dev": true, "optional": true @@ -26347,6 +26355,35 @@ "integrity": "sha512-d4sze1JNC454Wdo2fkuyzCr6aHcbL6PGGuFAz0Li/NcOm1tCHGnWDRmJP85dh9IhQErTc2svWFEX5xHIOo//kQ==", "dev": true }, + "handlebars": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz", + "integrity": "sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==", + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "neo-async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", + "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -28763,8 +28800,7 @@ "is-retry-allowed": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", - "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", - "dev": true + "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==" }, "is-root": { "version": "2.1.0", @@ -33044,6 +33080,11 @@ } } }, + "join-component": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/join-component/-/join-component-1.1.0.tgz", + "integrity": "sha1-uEF7dQZho5K+4sJTfGiyqdSXfNU=" + }, "js-base64": { "version": "2.4.5", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.5.tgz", @@ -33969,7 +34010,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "dev": true, "requires": { @@ -34471,6 +34512,11 @@ "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=", "dev": true }, + "lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha1-2ZwHpmnp5tJOE2Lf4mbGdhavEwI=" + }, "log-symbols": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", @@ -34855,7 +34901,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz", "integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=", - "dev": true, "requires": { "charenc": "~0.0.1", "crypt": "~0.0.1", @@ -37175,6 +37220,11 @@ "path-root": "^0.1.1" } }, + "parse-github-url": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-github-url/-/parse-github-url-1.0.2.tgz", + "integrity": "sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw==" + }, "parse-json": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-3.0.0.tgz", @@ -45662,6 +45712,11 @@ "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", "dev": true }, + "remove-trailing-slash": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-slash/-/remove-trailing-slash-0.1.0.tgz", + "integrity": "sha1-FJjl3wmEwn5Jt26/Boh8otARUNI=" + }, "renderkid": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.3.tgz", @@ -49938,7 +49993,6 @@ "version": "3.4.10", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.10.tgz", "integrity": "sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==", - "dev": true, "requires": { "commander": "~2.19.0", "source-map": "~0.6.1" @@ -49947,14 +50001,12 @@ "commander": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz", - "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==", - "dev": true + "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==" }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" } } }, diff --git a/package.json b/package.json index 02e0322d2..522af09e1 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@fluent/dom": "^0.6.0", "@hapi/joi": "^17.1.1", "@metascraper/helpers": "^5.11.6", + "@rudderstack/rudder-sdk-node": "0.0.2", "abort-controller": "^3.0.0", "akismet-api": "^5.0.0", "apollo-server-express": "^2.11.0", diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 14abe9118..f856ca5df 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -196,6 +196,9 @@ function configureApplicationViews(options: AppOptions) { // caching. watch: options.config.get("env") === "development", noCache: options.config.get("env") === "development", + // Trim whitespace in templates. + trimBlocks: true, + lstripBlocks: true, }); // assign the nunjucks engine to .njk and .html files. diff --git a/src/core/server/app/router/client.ts b/src/core/server/app/router/client.ts index 8fd725758..728a77fb8 100644 --- a/src/core/server/app/router/client.ts +++ b/src/core/server/app/router/client.ts @@ -1,5 +1,4 @@ import express, { Router } from "express"; -import { minify } from "html-minifier"; import { Db } from "mongodb"; import path from "path"; @@ -15,6 +14,29 @@ import { RequestHandler } from "coral-server/types/express"; import Entrypoints, { Entrypoint } from "../helpers/entrypoints"; export interface ClientTargetHandlerOptions { + /** + * analytics contains configuration for frontend analytics from RudderStack. + */ + analytics: { + /** + * key is the Write Key for the frontend integration. + */ + key: string; + + /** + * url is the URL to the data plane for your RudderStack deployment. + */ + url: string; + + /** + * sdk is the URL to the JS SDK for the RudderStack deployment. + */ + sdk: string; + }; + + /** + * defaultLocale is the configured fallback locale for this installation. + */ defaultLocale: LanguageCode; /** @@ -62,6 +84,11 @@ function createClientTargetRouter(options: ClientTargetHandlerOptions) { } interface MountClientRouteOptions { + analytics: { + key: string; + url: string; + sdk: string; + }; defaultLocale: LanguageCode; tenantCache: TenantCache; staticURI: string; @@ -69,6 +96,7 @@ interface MountClientRouteOptions { } const clientHandler = ({ + analytics, staticURI, entrypoint, enableCustomCSS, @@ -85,28 +113,19 @@ const clientHandler = ({ locale = req.coral.tenant.locale; } - res.render( - "client", - { staticURI, entrypoint, enableCustomCSS, locale, config }, - (err, html) => { - if (err) { - return next(err); - } - - // Send back the HTML minified. - res.send( - minify(html, { - removeComments: true, - collapseWhitespace: true, - }) - ); - } - ); + res.render("client", { + analytics, + staticURI, + entrypoint, + enableCustomCSS, + locale, + config, + }); }; export function mountClientRoutes( router: Router, - { staticURI, tenantCache, defaultLocale, mongo }: MountClientRouteOptions + { tenantCache, ...options }: MountClientRouteOptions ) { // TODO: (wyattjoh) figure out a better way of referencing paths. // Load the entrypoint manifest. @@ -141,31 +160,25 @@ export function mountClientRoutes( router.use( "/embed/stream", createClientTargetRouter({ - staticURI, + ...options, enableCustomCSS: true, entrypoint: entrypoints.get("stream"), - defaultLocale, - mongo, }) ); router.use( "/embed/auth/callback", createClientTargetRouter({ - staticURI, + ...options, cacheDuration: false, entrypoint: entrypoints.get("authCallback"), - defaultLocale, - mongo, }) ); router.use( "/embed/auth", createClientTargetRouter({ - staticURI, + ...options, cacheDuration: false, entrypoint: entrypoints.get("auth"), - defaultLocale, - mongo, }) ); @@ -175,11 +188,9 @@ export function mountClientRoutes( // If we aren't already installed, redirect the user to the install page. installedMiddleware(), createClientTargetRouter({ - staticURI, + ...options, cacheDuration: false, entrypoint: entrypoints.get("account"), - defaultLocale, - mongo, }) ); // Add the standalone targets. @@ -188,11 +199,9 @@ export function mountClientRoutes( // If we aren't already installed, redirect the user to the install page. installedMiddleware(), createClientTargetRouter({ - staticURI, + ...options, cacheDuration: false, entrypoint: entrypoints.get("admin"), - defaultLocale, - mongo, }) ); router.use( @@ -203,11 +212,9 @@ export function mountClientRoutes( redirectURL: "/admin", }), createClientTargetRouter({ - staticURI, + ...options, cacheDuration: false, entrypoint: entrypoints.get("install"), - defaultLocale, - mongo, }) ); diff --git a/src/core/server/app/router/index.ts b/src/core/server/app/router/index.ts index 687598bae..0e8ba3aab 100644 --- a/src/core/server/app/router/index.ts +++ b/src/core/server/app/router/index.ts @@ -27,6 +27,11 @@ export function createRouter(app: AppOptions, options: RouterOptions) { if (!options.disableClientRoutes) { mountClientRoutes(router, { + analytics: { + key: app.config.get("analytics_frontend_key"), + url: app.config.get("analytics_data_plane_url"), + sdk: app.config.get("analytics_frontend_sdk_url"), + }, defaultLocale: app.config.get("default_locale") as LanguageCode, // When mounting client routes, we need to provide a staticURI even when // not provided to the default current domain relative "/". diff --git a/src/core/server/app/views/client.html b/src/core/server/app/views/client.html index 0e80660d4..e46ca5f8f 100644 --- a/src/core/server/app/views/client.html +++ b/src/core/server/app/views/client.html @@ -13,6 +13,29 @@ {% if enableCustomCSS and tenant and tenant.customCSSURL %} {{ macros.preload(tenant.customCSSURL, "style") }} {% endif %} + {% if analytics and analytics.key and analytics.url and analytics.sdk %} + {# If analytics is enabled and available, configure the rudderstack analytics to load #} + + + {% endif %} {% if entrypoint.js %} {% for asset in entrypoint.js %} {{ macros.preload(asset.src, "script", prefix = staticURI) }} diff --git a/src/core/server/config.ts b/src/core/server/config.ts index b12e77e64..1d9fe2e7c 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -259,6 +259,30 @@ const config = convict({ default: false, env: "DISABLE_JOB_PROCESSORS", }, + analytics_frontend_key: { + doc: "Analytics write key from RudderStack for the Javascript client.", + format: String, + default: "", + env: "ANALYTICS_FRONTEND_KEY", + }, + analytics_backend_key: { + doc: "Analytics write key from RudderStack for the Node server.", + format: String, + default: "", + env: "ANALYTICS_BACKEND_KEY", + }, + analytics_frontend_sdk_url: { + doc: "Analytics URL to the RudderStack Frontend JS SDK. Defaults to the ", + format: "url", + default: "https://cdn.rudderlabs.com/v1/rudder-analytics.min.js", + env: "ANALYTICS_FRONTEND_SDK_URL", + }, + analytics_data_plane_url: { + doc: "Analytics URL to the RudderStack data plane instance.", + format: "optional-url", + default: "", + env: "ANALYTICS_DATA_PLANE_URL", + }, }); export type Config = typeof config; diff --git a/src/core/server/events/events.ts b/src/core/server/events/events.ts index d7dbf7514..dbe49aa33 100644 --- a/src/core/server/events/events.ts +++ b/src/core/server/events/events.ts @@ -7,6 +7,7 @@ import { CommentReplyCreatedInput, CommentStatusUpdatedInput, } from "coral-server/graph/resolvers/Subscription"; +import { FLAG_REASON } from "coral-server/models/action/comment"; import { CoralEventPayload, createCoralEvent } from "./event"; import { CoralEventType } from "./types"; @@ -20,6 +21,39 @@ export const CommentEnteredModerationQueueCoralEvent = createCoralEvent< CommentEnteredModerationQueueCoralEventPayload >(CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE); +export type CommentReactionCreatedCoralEventPayload = CoralEventPayload< + CoralEventType.COMMENT_REACTION_CREATED, + { + commentID: string; + commentRevisionID: string; + commentParentID?: string; + actionUserID: string; + storyID: string; + siteID: string; + } +>; + +export const CommentReactionCreatedCoralEvent = createCoralEvent< + CommentReactionCreatedCoralEventPayload +>(CoralEventType.COMMENT_REACTION_CREATED); + +export type CommentFlagCreatedCoralEventPayload = CoralEventPayload< + CoralEventType.COMMENT_FLAG_CREATED, + { + commentID: string; + commentRevisionID: string; + commentParentID?: string; + flagReason: FLAG_REASON; + actionUserID: string; + storyID: string; + siteID: string; + } +>; + +export const CommentFlagCreatedCoralEvent = createCoralEvent< + CommentFlagCreatedCoralEventPayload +>(CoralEventType.COMMENT_FLAG_CREATED); + export type CommentLeftModerationQueueCoralEventPayload = CoralEventPayload< CoralEventType.COMMENT_LEFT_MODERATION_QUEUE, CommentLeftModerationQueueInput diff --git a/src/core/server/events/listeners/analytics.ts b/src/core/server/events/listeners/analytics.ts new file mode 100644 index 000000000..bbb22e67d --- /dev/null +++ b/src/core/server/events/listeners/analytics.ts @@ -0,0 +1,221 @@ +import Analytics, { Event } from "@rudderstack/rudder-sdk-node"; + +import { Config } from "coral-server/config"; +import GraphContext from "coral-server/graph/context"; +import { relativeTo } from "coral-server/helpers"; +import logger from "coral-server/logger"; + +import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; + +import { + CommentCreatedCoralEventPayload, + CommentFlagCreatedCoralEventPayload, + CommentReactionCreatedCoralEventPayload, + CommentReplyCreatedCoralEventPayload, + CommentStatusUpdatedCoralEventPayload, + StoryCreatedCoralEventPayload, +} from "../events"; +import { CoralEventListener, CoralEventPublisherFactory } from "../publisher"; +import { CoralEventType } from "../types"; + +type AnalyticsCoralEventListenerPayloads = + | CommentStatusUpdatedCoralEventPayload + | CommentCreatedCoralEventPayload + | CommentReplyCreatedCoralEventPayload + | CommentReactionCreatedCoralEventPayload + | CommentFlagCreatedCoralEventPayload + | StoryCreatedCoralEventPayload; + +export class AnalyticsCoralEventListener + implements CoralEventListener { + public readonly name = "analytics"; + public readonly events = [ + CoralEventType.COMMENT_CREATED, + CoralEventType.COMMENT_REPLY_CREATED, + CoralEventType.COMMENT_STATUS_UPDATED, + CoralEventType.COMMENT_REACTION_CREATED, + CoralEventType.COMMENT_FLAG_CREATED, + CoralEventType.STORY_CREATED, + ]; + + public readonly disabled: boolean = false; + private readonly analytics: Analytics; + + constructor(config: Config) { + const key = config.get("analytics_backend_key"); + const url = config.get("analytics_data_plane_url"); + if (!key || !url) { + this.disabled = true; + + return; + } + + this.analytics = new Analytics(key, relativeTo("/v1/batch", url)); + } + + private filter(event: AnalyticsCoralEventListenerPayloads): boolean { + switch (event.type) { + case CoralEventType.COMMENT_CREATED: + case CoralEventType.COMMENT_REPLY_CREATED: + case CoralEventType.COMMENT_REACTION_CREATED: + case CoralEventType.COMMENT_FLAG_CREATED: + case CoralEventType.STORY_CREATED: + return true; + case CoralEventType.COMMENT_STATUS_UPDATED: + // We only record when a comment has been rejected/approved. + if ( + event.data.newStatus !== GQLCOMMENT_STATUS.APPROVED && + event.data.newStatus !== GQLCOMMENT_STATUS.REJECTED + ) { + return false; + } + + return true; + default: + return false; + } + } + + private async create( + ctx: GraphContext, + event: AnalyticsCoralEventListenerPayloads + ): Promise | undefined> { + switch (event.type) { + case CoralEventType.COMMENT_CREATED: + case CoralEventType.COMMENT_REPLY_CREATED: { + const [comment, story] = await Promise.all([ + ctx.loaders.Comments.comment.load(event.data.commentID), + ctx.loaders.Stories.story.load(event.data.storyID), + ]); + if (!comment || !story || !comment.authorID) { + return; + } + + return { + event: "Comment Created", + properties: { + siteID: comment.siteID, + storyID: comment.storyID, + storyURL: story.url, + commentID: comment.id, + commentStatus: comment.status, + commentIsReply: !!comment.parentID, + commentAuthorID: comment.authorID, + }, + }; + } + + case CoralEventType.STORY_CREATED: { + return { + event: "Story Created", + properties: { + siteID: event.data.siteID, + storyID: event.data.storyID, + storyURL: event.data.storyURL, + }, + }; + } + + case CoralEventType.COMMENT_REACTION_CREATED: { + const story = await ctx.loaders.Stories.story.load(event.data.storyID); + if (!story) { + return; + } + + return { + event: "Comment Reaction Created", + properties: { + siteID: story.siteID, + storyID: story.id, + storyURL: story.url, + commentID: event.data.commentID, + commentIsReply: !!event.data.commentParentID, + actionUserID: event.data.actionUserID, + }, + }; + } + + case CoralEventType.COMMENT_FLAG_CREATED: { + const story = await ctx.loaders.Stories.story.load(event.data.storyID); + if (!story) { + return; + } + + return { + event: "User Flagged Comment", + properties: { + siteID: story.siteID, + storyID: story.id, + storyURL: story.url, + commentID: event.data.commentID, + commentIsReply: !!event.data.commentParentID, + actionUserID: event.data.actionUserID, + flagReason: event.data.flagReason, + }, + }; + } + + case CoralEventType.COMMENT_STATUS_UPDATED: { + const story = await ctx.loaders.Stories.story.load(event.data.storyID); + if (!story) { + return; + } + + return { + event: "Comment Moderated", + properties: { + siteID: story.siteID, + storyID: story.id, + storyURL: story.url, + commentID: event.data.commentID, + commentStatus: event.data.newStatus, + commentPreviousStatus: event.data.oldStatus, + }, + }; + } + } + } + + public initialize: CoralEventPublisherFactory< + AnalyticsCoralEventListenerPayloads + > = (ctx) => { + return async (event) => { + // Check to see if we should process this event. + if (!this.filter(event)) { + // The event should not be processed. + return; + } + + // Create the event payload. + const details = await this.create(ctx, event); + if (!details) { + return; + } + + // Pull some properties out of the context. + const { + // Sometimes, the user isn't defined (an anonymous request), so default + // so we don't get any spread errors. + user: { id: userId } = {}, + tenant: { id: tenantID, domain: tenantDomain }, + } = ctx; + + // Assemble the track payload. + const payload: Event = { + event: details.event, + userId, + properties: { + ...details.properties, + tenantID, + tenantDomain, + }, + timestamp: event.createdAt, + }; + + logger.debug({ payload }, "sending analytics event"); + + // Send the event payload to analytics. + return this.analytics.track(payload); + }; + }; +} diff --git a/src/core/server/events/listeners/index.ts b/src/core/server/events/listeners/index.ts index 64d14316b..7c0ca1f14 100644 --- a/src/core/server/events/listeners/index.ts +++ b/src/core/server/events/listeners/index.ts @@ -1,3 +1,4 @@ +export * from "./analytics"; export * from "./notifier"; export * from "./perspective"; export * from "./slack"; diff --git a/src/core/server/events/publisher.ts b/src/core/server/events/publisher.ts index a85b2b735..64ecd630b 100644 --- a/src/core/server/events/publisher.ts +++ b/src/core/server/events/publisher.ts @@ -20,6 +20,11 @@ export abstract class CoralEventListener { */ public abstract readonly name: string; + /** + * disabled if true will disable the event listener from handling requests. + */ + public abstract readonly disabled?: boolean; + /** * events is the array of event types that this listener should listen for. */ @@ -109,6 +114,11 @@ export default class CoralEventListenerBroker { return; } + if (listener.disabled) { + logger.warn({ listenerName: listener.name }, "listener was disabled"); + return; + } + logger.trace( { listenerName: listener.name, listenerEvents: listener.events }, "registering listener for events" diff --git a/src/core/server/events/types.ts b/src/core/server/events/types.ts index 9737f1298..346945563 100644 --- a/src/core/server/events/types.ts +++ b/src/core/server/events/types.ts @@ -7,4 +7,6 @@ export enum CoralEventType { COMMENT_FEATURED = "COMMENT_FEATURED", COMMENT_RELEASED = "COMMENT_RELEASED", STORY_CREATED = "STORY_CREATED", + COMMENT_REACTION_CREATED = "COMMENT_REACTION_CREATED", + COMMENT_FLAG_CREATED = "COMMENT_FLAG_CREATED", } diff --git a/src/core/server/graph/resolvers/Subscription/commentReplyCreated.ts b/src/core/server/graph/resolvers/Subscription/commentReplyCreated.ts index 12f0f5e3a..a851a4ab4 100644 --- a/src/core/server/graph/resolvers/Subscription/commentReplyCreated.ts +++ b/src/core/server/graph/resolvers/Subscription/commentReplyCreated.ts @@ -10,6 +10,7 @@ import { export interface CommentReplyCreatedInput extends SubscriptionPayload { ancestorIDs: string[]; commentID: string; + storyID: string; } export type CommentReplyCreatedSubscription = SubscriptionType< diff --git a/src/core/server/graph/resolvers/Subscription/commentStatusUpdated.ts b/src/core/server/graph/resolvers/Subscription/commentStatusUpdated.ts index 562624e29..3a778b43d 100644 --- a/src/core/server/graph/resolvers/Subscription/commentStatusUpdated.ts +++ b/src/core/server/graph/resolvers/Subscription/commentStatusUpdated.ts @@ -16,6 +16,7 @@ export interface CommentStatusUpdatedInput extends SubscriptionPayload { moderatorID: string | null; commentID: string; commentRevisionID: string; + storyID: string; } export type CommentStatusUpdatedSubscription = SubscriptionType< diff --git a/src/core/server/helpers/index.ts b/src/core/server/helpers/index.ts index f2469b295..8f34bf25e 100644 --- a/src/core/server/helpers/index.ts +++ b/src/core/server/helpers/index.ts @@ -1 +1,2 @@ export { default as createTimer } from "./createTimer"; +export { default as relativeTo } from "./relativeTo"; diff --git a/src/core/server/helpers/relativeTo.spec.ts b/src/core/server/helpers/relativeTo.spec.ts new file mode 100644 index 000000000..5f6d71773 --- /dev/null +++ b/src/core/server/helpers/relativeTo.spec.ts @@ -0,0 +1,7 @@ +import relativeTo from "./relativeTo"; + +it("strips the leading / from urls", () => { + expect( + relativeTo("/root/test", "https://coralproject.net/another/path/") + ).toEqual("https://coralproject.net/another/path/root/test"); +}); diff --git a/src/core/server/helpers/relativeTo.ts b/src/core/server/helpers/relativeTo.ts new file mode 100644 index 000000000..df5035e74 --- /dev/null +++ b/src/core/server/helpers/relativeTo.ts @@ -0,0 +1,7 @@ +import { URL } from "url"; + +function relativeTo(input: string, base: string): string { + return new URL(input.startsWith("/") ? input.slice(1) : input, base).href; +} + +export default relativeTo; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index f677d9c06..a69e4fd3f 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -36,6 +36,7 @@ import { import { TenantCache } from "coral-server/services/tenant/cache"; import { + AnalyticsCoralEventListener, NotifierCoralEventListener, PerspectiveCoralEventListener, SlackCoralEventListener, @@ -64,10 +65,10 @@ class Server { private parentApp: Express; // schema is the GraphQL Schema that relates to the given Tenant. - private schema: GraphQLSchema; + private readonly schema: GraphQLSchema; // config exposes application specific configuration. - public config: Config; + public readonly config: Config; // httpServer is the running instance of the HTTP server that will bind to // the requested port. @@ -102,10 +103,10 @@ class Server { private processing = false; // i18n is the server reference to the i18n framework. - private i18n: I18n; + private readonly i18n: I18n; // signingConfig is the server reference to the signing configuration. - private signingConfig: JWTSigningConfig; + private readonly signingConfig: JWTSigningConfig; // persistedQueryCache is the cache of persisted queries used by the GraphQL // server to handle persisted queries. @@ -216,6 +217,7 @@ class Server { // Setup the broker. this.broker = new CoralEventListenerBroker(); + this.broker.register(new AnalyticsCoralEventListener(this.config)); this.broker.register(new NotifierCoralEventListener(this.tasks.notifier)); this.broker.register(new SlackCoralEventListener()); this.broker.register(new SubscriptionCoralEventListener()); diff --git a/src/core/server/models/action/comment.spec.ts b/src/core/server/models/action/comment.spec.ts index 26306666b..5e3fede88 100644 --- a/src/core/server/models/action/comment.spec.ts +++ b/src/core/server/models/action/comment.spec.ts @@ -1,4 +1,3 @@ -import { GQLCOMMENT_FLAG_REASON } from "coral-server/graph/schema/__generated__/types"; import { ACTION_TYPE, CommentAction, @@ -9,6 +8,8 @@ import { validateAction, } from "coral-server/models/action/comment"; +import { GQLCOMMENT_FLAG_REASON } from "coral-server/graph/schema/__generated__/types"; + describe("#encodeActionCounts", () => { it("generates the action counts correctly", () => { const actions: Array> = [ diff --git a/src/core/server/services/comments/actions.ts b/src/core/server/services/comments/actions.ts index 0f38fe18a..a939c8c69 100644 --- a/src/core/server/services/comments/actions.ts +++ b/src/core/server/services/comments/actions.ts @@ -4,6 +4,7 @@ import { CommentNotFoundError } from "coral-server/errors"; import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { ACTION_TYPE, + CommentAction, CreateActionInput, createActions, encodeActionCounts, @@ -28,6 +29,10 @@ import { } from "coral-server/stacks/helpers"; import { GQLCOMMENT_FLAG_REPORTED_REASON } from "coral-server/graph/schema/__generated__/types"; +import { + publishCommentFlagCreated, + publishCommentReactionCreated, +} from "../events"; export type CreateAction = CreateActionInput; @@ -72,6 +77,11 @@ export async function addCommentActionCounts( return updatedComment; } +interface AddCommentAction { + comment: Readonly; + action?: CommentAction; +} + async function addCommentAction( mongo: Db, redis: AugmentedRedis, @@ -79,7 +89,7 @@ async function addCommentAction( tenant: Tenant, input: Omit, now = new Date() -): Promise> { +): Promise { const oldComment = await retrieveComment(mongo, tenant.id, input.commentID); if (!oldComment) { throw new CommentNotFoundError(input.commentID); @@ -95,6 +105,9 @@ async function addCommentAction( // Update the actions for the comment. const commentActions = await addCommentActions(mongo, tenant, [action], now); if (commentActions.length > 0) { + // Get the comment action. + const [commentAction] = commentActions; + // Compute the action counts. const actionCounts = encodeActionCounts(...commentActions); @@ -122,10 +135,10 @@ async function addCommentAction( commentRevisionID: input.commentRevisionID, }); - return updatedComment; + return { comment: updatedComment, action: commentAction }; } - return oldComment; + return { comment: oldComment }; } export async function removeCommentAction( @@ -218,7 +231,7 @@ export async function createReaction( input: CreateCommentReaction, now = new Date() ) { - return addCommentAction( + const { comment, action } = await addCommentAction( mongo, redis, broker, @@ -231,6 +244,17 @@ export async function createReaction( }, now ); + if (action) { + // A comment reaction was created! Publish it. + publishCommentReactionCreated( + broker, + comment, + input.commentRevisionID, + action + ); + } + + return comment; } export type RemoveCommentReaction = Pick; @@ -264,7 +288,7 @@ export async function createDontAgree( input: CreateCommentDontAgree, now = new Date() ) { - return addCommentAction( + const { comment } = await addCommentAction( mongo, redis, broker, @@ -278,6 +302,8 @@ export async function createDontAgree( }, now ); + + return comment; } export type RemoveCommentDontAgree = Pick; @@ -313,7 +339,7 @@ export async function createFlag( input: CreateCommentFlag, now = new Date() ) { - return addCommentAction( + const { comment, action } = await addCommentAction( mongo, redis, broker, @@ -328,4 +354,10 @@ export async function createFlag( }, now ); + if (action) { + // A action was created! Publish the event. + publishCommentFlagCreated(broker, comment, input.commentRevisionID, action); + } + + return comment; } diff --git a/src/core/server/services/events/comments.ts b/src/core/server/services/events/comments.ts index 192e9ed7b..0cbcd7476 100644 --- a/src/core/server/services/events/comments.ts +++ b/src/core/server/services/events/comments.ts @@ -2,12 +2,15 @@ import { CommentCreatedCoralEvent, CommentEnteredModerationQueueCoralEvent, CommentFeaturedCoralEvent, + CommentFlagCreatedCoralEvent, CommentLeftModerationQueueCoralEvent, + CommentReactionCreatedCoralEvent, CommentReleasedCoralEvent, CommentReplyCreatedCoralEvent, CommentStatusUpdatedCoralEvent, } from "coral-server/events"; import { CoralEventPublisherBroker } from "coral-server/events/publisher"; +import { CommentAction } from "coral-server/models/action/comment"; import { Comment, CommentModerationQueueCounts, @@ -26,6 +29,7 @@ export async function publishCommentStatusChanges( newStatus: GQLCOMMENT_STATUS, commentID: string, commentRevisionID: string, + storyID: string, moderatorID: string | null ) { if (oldStatus !== newStatus) { @@ -34,6 +38,7 @@ export async function publishCommentStatusChanges( oldStatus, commentID, commentRevisionID, + storyID, moderatorID, }); } @@ -41,12 +46,13 @@ export async function publishCommentStatusChanges( export async function publishCommentReplyCreated( broker: CoralEventPublisherBroker, - comment: Pick + comment: Pick ) { if (getDepth(comment) > 0 && hasPublishedStatus(comment)) { await CommentReplyCreatedCoralEvent.publish(broker, { ancestorIDs: comment.ancestorIDs, commentID: comment.id, + storyID: comment.storyID, }); } } @@ -75,6 +81,46 @@ export async function publishCommentReleased( } } +export async function publishCommentReactionCreated( + broker: CoralEventPublisherBroker, + comment: Pick, + commentRevisionID: string, + { userID }: Pick +) { + // We only publish reaction created events for reactions created by users. + if (userID) { + await CommentReactionCreatedCoralEvent.publish(broker, { + commentID: comment.id, + commentRevisionID, + commentParentID: comment.parentID, + actionUserID: userID, + storyID: comment.storyID, + siteID: comment.siteID, + }); + } +} + +export async function publishCommentFlagCreated( + broker: CoralEventPublisherBroker, + comment: Pick, + commentRevisionID: string, + { userID, reason }: Pick +) { + // We only publish flag created events for flags created by the system with + // a reason. + if (userID && reason) { + await CommentFlagCreatedCoralEvent.publish(broker, { + commentID: comment.id, + commentRevisionID, + commentParentID: comment.parentID, + actionUserID: userID, + flagReason: reason, + storyID: comment.storyID, + siteID: comment.siteID, + }); + } +} + export async function publishCommentFeatured( broker: CoralEventPublisherBroker, comment: Pick diff --git a/src/core/server/stacks/helpers/publishChanges.ts b/src/core/server/stacks/helpers/publishChanges.ts index f423cbd96..abd3e5bc4 100644 --- a/src/core/server/stacks/helpers/publishChanges.ts +++ b/src/core/server/stacks/helpers/publishChanges.ts @@ -37,6 +37,7 @@ export default async function publishChanges( input.after.status, input.after.id, input.commentRevisionID, + input.after.storyID, input.moderatorID || null ); diff --git a/src/types/@rudderstack/rudder-sdk-node.d.ts b/src/types/@rudderstack/rudder-sdk-node.d.ts new file mode 100644 index 000000000..c00652843 --- /dev/null +++ b/src/types/@rudderstack/rudder-sdk-node.d.ts @@ -0,0 +1,18 @@ +declare module "@rudderstack/rudder-sdk-node" { + export interface Event { + userId?: string; + event: string; + properties: Record; + timestamp?: Date; + } + + type Callback = (err?: Error) => void; + + class Analytics { + constructor(writeKey: string, dataPlaneURI: string); + + public track(payload: Event, callback?: Callback): void; + } + + export default Analytics; +}