diff --git a/.github/workflows/es-check.yml b/.github/workflows/es-check.yml index 16e7ebed5..3602d3751 100644 --- a/.github/workflows/es-check.yml +++ b/.github/workflows/es-check.yml @@ -5,7 +5,7 @@ on: jobs: ssr: - name: Cypress + name: Build and check ES5/ES6 support runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -21,3 +21,6 @@ jobs: - name: Run es-check to check if our ie11 bundle is ES5 compatible run: npx es-check@7.2.1 es5 dist/array.full.es5.js + + - name: Run es-check to check if our main bundle is ES6 compatible + run: npx es-check@7.2.1 es6 dist/array.full.js diff --git a/.github/workflows/testcafe.yml b/.github/workflows/testcafe.yml index fb90e63c8..7fffb7e3f 100644 --- a/.github/workflows/testcafe.yml +++ b/.github/workflows/testcafe.yml @@ -54,6 +54,7 @@ jobs: run: pnpm build-rollup - name: Run ${{ matrix.name }} test + timeout-minutes: 10 env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} RUN_ID: ${{ github.run_id }} @@ -61,4 +62,5 @@ jobs: run: pnpm testcafe ${{ matrix.browser }} --stop-on-first-fail - name: Check ${{ matrix.name }} events + timeout-minutes: 10 run: pnpm check-testcafe-results diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b44bdb7..8bb35569d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,50 @@ +## 1.187.2 - 2024-11-20 + +- fix: improve ES6 bundling (#1542) + +## 1.187.1 - 2024-11-19 + +- fix: patch angular wrap detection in rrweb (#1543) + +## 1.187.0 - 2024-11-19 + +- feat: allow config of before_send function to edit or reject events (#1515) +- chore: timeout test cafe jobs (#1540) +- chore: specify an explicit browserslist version (#1539) + +## 1.186.4 - 2024-11-19 + +- chore: always transform exponentiation (#1537) +- chore: very small change to IE11 bundling (#1536) + +## 1.186.3 - 2024-11-18 + +- fix: refactor native mutation observer implementation (#1535) +- chore: update dependency versions (#1534) +- chore: remove custom exceptions endpoint (#1513) + +## 1.186.2 - 2024-11-18 + +- fix: angular change detection mutation observer (#1531) +- chore: Added CSP headers to next app for testing what we document (#1528) + +## 1.186.1 - 2024-11-15 + +- fix: XHR req method capture (#1527) + +## 1.186.0 - 2024-11-15 + +- feat: allow triggering sessions when events occur (#1523) + +## 1.185.0 - 2024-11-15 + +- feat: Add customization to add all person profile properties as setPersonPropertiesForFlags (#1517) + +## 1.184.2 - 2024-11-13 + +- fix(flags): support multiple children prop in PostHogFeature (#1516) +- fix: Don't use session storage in memory mode (#1521) + ## 1.184.1 - 2024-11-12 - chore: add type to Sentry exception (#1520) diff --git a/cypress/e2e/before_send.cy.ts b/cypress/e2e/before_send.cy.ts new file mode 100644 index 000000000..1502e69b4 --- /dev/null +++ b/cypress/e2e/before_send.cy.ts @@ -0,0 +1,48 @@ +/// + +import { start } from '../support/setup' +import { isArray } from '../../src/utils/type-utils' + +describe('before_send', () => { + it('can sample and edit with before_send', () => { + start({}) + + cy.posthog().then((posthog) => { + let counter = 0 + const og = posthog.config.before_send + // cypress tests rely on existing before_send function to capture events + // so we have to add it back in here + posthog.config.before_send = [ + (cr) => { + if (cr.event === 'custom-event') { + counter++ + if (counter === 2) { + return null + } + } + if (cr.event === '$autocapture') { + return { + ...cr, + event: 'redacted', + } + } + return cr + }, + ...(isArray(og) ? og : [og]), + ] + }) + + cy.get('[data-cy-custom-event-button]').click() + cy.get('[data-cy-custom-event-button]').click() + + cy.phCaptures().should('deep.equal', [ + // before adding the new before sendfn + '$pageview', + 'redacted', + 'custom-event', + // second button click only has the redacted autocapture event + 'redacted', + // because the second custom-event is rejected + ]) + }) +}) diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts index f247b383d..56f1134b4 100644 --- a/cypress/e2e/session-recording.cy.ts +++ b/cypress/e2e/session-recording.cy.ts @@ -280,6 +280,62 @@ describe('Session recording', () => { ) }) }) + + it('it captures XHR method correctly', () => { + cy.get('[data-cy-xhr-call-button]').click() + cy.wait('@example.com') + cy.wait('@session-recording') + cy.phCaptures({ full: true }).then((captures) => { + const snapshots = captures.filter((c) => c.event === '$snapshot') + + const capturedRequests: Record[] = [] + for (const snapshot of snapshots) { + for (const snapshotData of snapshot.properties['$snapshot_data']) { + if (snapshotData.type === 6) { + for (const req of snapshotData.data.payload.requests) { + capturedRequests.push(req) + } + } + } + } + + const expectedCaptureds: [RegExp, string][] = [ + [/http:\/\/localhost:\d+\/playground\/cypress\//, 'navigation'], + [/http:\/\/localhost:\d+\/static\/array.js/, 'script'], + [ + /http:\/\/localhost:\d+\/decide\/\?v=3&ip=1&_=\d+&ver=1\.\d\d\d\.\d+&compression=base64/, + 'xmlhttprequest', + ], + [/http:\/\/localhost:\d+\/static\/recorder.js\?v=1\.\d\d\d\.\d+/, 'script'], + [/https:\/\/example.com/, 'xmlhttprequest'], + ] + + // yay, includes expected type 6 network data + expect(capturedRequests.length).to.equal(expectedCaptureds.length) + expectedCaptureds.forEach(([url, initiatorType], index) => { + expect(capturedRequests[index].name).to.match(url) + expect(capturedRequests[index].initiatorType).to.equal(initiatorType) + }) + + // the HTML file that cypress is operating on (playground/cypress/index.html) + // when the button for this test is click makes a post to https://example.com + const capturedFetchRequest = capturedRequests.find((cr) => cr.name === 'https://example.com/') + expect(capturedFetchRequest).to.not.be.undefined + + expect(capturedFetchRequest.fetchStart).to.be.greaterThan(0) // proxy for including network timing info + + expect(capturedFetchRequest.initiatorType).to.eql('xmlhttprequest') + expect(capturedFetchRequest.method).to.eql('POST') + expect(capturedFetchRequest.isInitial).to.be.undefined + expect(capturedFetchRequest.requestBody).to.eq('i am the xhr body') + + expect(capturedFetchRequest.responseBody).to.eq( + JSON.stringify({ + message: 'This is a JSON response', + }) + ) + }) + }) }) }) @@ -654,7 +710,7 @@ describe('Session recording', () => { cy.posthog().invoke('capture', 'test_registered_property') cy.phCaptures({ full: true }).then((captures) => { expect((captures || []).map((c) => c.event)).to.deep.equal(['$pageview', 'test_registered_property']) - expect(captures[1]['properties']['$session_recording_start_reason']).to.equal('sampling_override') + expect(captures[1]['properties']['$session_recording_start_reason']).to.equal('sampling_overridden') }) cy.resetPhCaptures() diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 11a219bb2..fd85d005e 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -9,9 +9,10 @@ Cypress.Commands.add('posthogInit', (options) => { cy.posthog().invoke('init', 'test_token', { api_host: location.origin, debug: true, - _onCapture: (event, eventData) => { - $captures.push(event) - $fullCaptures.push(eventData) + before_send: (event) => { + $captures.push(event.event) + $fullCaptures.push(event) + return event }, opt_out_useragent_filter: true, ...options, diff --git a/eslint-rules/no-direct-mutation-observer.js b/eslint-rules/no-direct-mutation-observer.js new file mode 100644 index 000000000..38ab4705d --- /dev/null +++ b/eslint-rules/no-direct-mutation-observer.js @@ -0,0 +1,28 @@ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow direct use of MutationObserver and enforce importing NativeMutationObserver from global.ts', + category: 'Best Practices', + recommended: false, + }, + schema: [], + messages: { + noDirectMutationObserver: + 'Direct use of MutationObserver is not allowed. Use NativeMutationObserver from global.ts instead.', + }, + }, + create(context) { + return { + NewExpression(node) { + if (node.callee.type === 'Identifier' && node.callee.name === 'MutationObserver') { + context.report({ + node, + messageId: 'noDirectMutationObserver', + }) + } + }, + } + }, +} diff --git a/package.json b/package.json index 4c9a8ae7f..8b5e20c78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posthog-js", - "version": "1.184.1", + "version": "1.187.2", "description": "Posthog-js allows you to automatically capture usage and send events to PostHog.", "repository": "https://github.com/PostHog/posthog-js", "author": "hey@posthog.com", @@ -46,6 +46,7 @@ "devDependencies": { "@babel/core": "7.18.9", "@babel/plugin-syntax-decorators": "^7.23.3", + "@babel/plugin-transform-exponentiation-operator": "^7.25.9", "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.8", "@babel/plugin-transform-react-jsx": "^7.23.4", "@babel/preset-env": "7.18.9", @@ -74,6 +75,7 @@ "@typescript-eslint/eslint-plugin": "^8.2.0", "@typescript-eslint/parser": "^8.2.0", "babel-jest": "^26.6.3", + "browserslist": "^4.24.2", "compare-versions": "^6.1.0", "cypress": "13.6.3", "cypress-localstorage-commands": "^2.2.6", @@ -81,7 +83,7 @@ "eslint": "8.57.0", "eslint-config-posthog-js": "link:eslint-rules", "eslint-config-prettier": "^8.5.0", - "eslint-plugin-compat": "^4.1.4", + "eslint-plugin-compat": "^6.0.1", "eslint-plugin-jest": "^28.8.3", "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-posthog-js": "link:eslint-rules", @@ -89,7 +91,6 @@ "eslint-plugin-react": "^7.30.1", "eslint-plugin-react-hooks": "^4.6.0", "expect": "^29.7.0", - "express": "^4.19.2", "fast-check": "^2.17.0", "husky": "^8.0.1", "jest": "^27.5.1", diff --git a/patches/@rrweb__record@2.0.0-alpha.17.patch b/patches/@rrweb__record@2.0.0-alpha.17.patch index 56ad4aedd..9f5ab8084 100644 --- a/patches/@rrweb__record@2.0.0-alpha.17.patch +++ b/patches/@rrweb__record@2.0.0-alpha.17.patch @@ -1,8 +1,32 @@ diff --git a/dist/record.js b/dist/record.js -index 46ec389fefb698243008b39db65470dbdf0a3857..39d9ac821fd84f5a1494ac1e00c8d4e0e58c432a 100644 +index 46ec389fefb698243008b39db65470dbdf0a3857..891e1cf6439630d19e9b745ff428db438943a0b2 100644 --- a/dist/record.js +++ b/dist/record.js -@@ -246,6 +246,9 @@ function isCSSImportRule(rule2) { +@@ -26,6 +26,14 @@ const testableMethods$1 = { + Element: [], + MutationObserver: ["constructor"] + }; ++const isFunction = (x) => typeof x === 'function'; ++const isAngularZonePatchedFunction = (x) => { ++ if (!isFunction(x)) { ++ return false; ++ } ++ const prototypeKeys = Object.getOwnPropertyNames(x.prototype || {}); ++ return prototypeKeys.some((key) => key.indexOf('__zone')); ++} + const untaintedBasePrototype$1 = {}; + function getUntaintedPrototype$1(key) { + if (untaintedBasePrototype$1[key]) +@@ -54,7 +62,7 @@ function getUntaintedPrototype$1(key) { + } + ) + ); +- if (isUntaintedAccessors && isUntaintedMethods) { ++ if (isUntaintedAccessors && isUntaintedMethods && !isAngularZonePatchedFunction(defaultObj)) { + untaintedBasePrototype$1[key] = defaultObj.prototype; + return defaultObj.prototype; + } +@@ -246,6 +254,9 @@ function isCSSImportRule(rule2) { function isCSSStyleRule(rule2) { return "selectorText" in rule2; } @@ -12,7 +36,7 @@ index 46ec389fefb698243008b39db65470dbdf0a3857..39d9ac821fd84f5a1494ac1e00c8d4e0 class Mirror { constructor() { __publicField$1(this, "idNodeMap", /* @__PURE__ */ new Map()); -@@ -809,9 +812,14 @@ function serializeElementNode(n2, options) { +@@ -809,9 +820,14 @@ function serializeElementNode(n2, options) { } } if (tagName === "link" && inlineStylesheet) { @@ -30,7 +54,7 @@ index 46ec389fefb698243008b39db65470dbdf0a3857..39d9ac821fd84f5a1494ac1e00c8d4e0 let cssText = null; if (stylesheet) { cssText = stringifyStylesheet(stylesheet); -@@ -1116,7300 +1124,227 @@ function serializeNodeWithId(n2, options) { +@@ -1116,7300 +1132,227 @@ function serializeNodeWithId(n2, options) { keepIframeSrcFn }; if (serializedNode.type === NodeType$2.Element && serializedNode.tagName === "textarea" && serializedNode.attributes.value !== void 0) ; @@ -7540,7 +7564,16 @@ index 46ec389fefb698243008b39db65470dbdf0a3857..39d9ac821fd84f5a1494ac1e00c8d4e0 class BaseRRNode { // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any constructor(..._args) { -@@ -11382,11 +4317,19 @@ class CanvasManager { +@@ -8507,7 +1450,7 @@ function getUntaintedPrototype(key) { + } + ) + ); +- if (isUntaintedAccessors && isUntaintedMethods) { ++ if (isUntaintedAccessors && isUntaintedMethods && !isAngularZonePatchedFunction(defaultObj)) { + untaintedBasePrototype[key] = defaultObj.prototype; + return defaultObj.prototype; + } +@@ -11382,11 +4325,19 @@ class CanvasManager { let rafId; const getCanvas = () => { const matchedCanvas = []; @@ -7565,7 +7598,7 @@ index 46ec389fefb698243008b39db65470dbdf0a3857..39d9ac821fd84f5a1494ac1e00c8d4e0 return matchedCanvas; }; const takeCanvasSnapshots = (timestamp) => { -@@ -11407,13 +4350,20 @@ class CanvasManager { +@@ -11407,13 +4358,20 @@ class CanvasManager { context.clear(context.COLOR_BUFFER_BIT); } } diff --git a/playground/copy-autocapture/demo.html b/playground/copy-autocapture/demo.html index 52f81cb5a..3afd12a59 100644 --- a/playground/copy-autocapture/demo.html +++ b/playground/copy-autocapture/demo.html @@ -10,13 +10,14 @@ loaded: function(posthog) { posthog.identify('test') }, - _onCapture: (event, eventData) => { - if (event === '$copy_autocapture') { - const selectionType = eventData.properties['$copy_type'] - const selectionText = eventData.properties['$selected_content'] + before_send: (event) => { + if (event.event === '$copy_autocapture') { + const selectionType = event.properties['$copy_type'] + const selectionText = event.properties['$selected_content'] document.getElementById('selection-type-outlet').innerText = selectionType document.getElementById('selection-text-outlet').innerText = selectionText } + return event }, }) diff --git a/playground/cypress/index.html b/playground/cypress/index.html index 06830c7c9..b078af68e 100644 --- a/playground/cypress/index.html +++ b/playground/cypress/index.html @@ -19,6 +19,28 @@ Make network call + + +
External link {isClient && typeof window !== 'undefined' && process.env.NEXT_PUBLIC_CROSSDOMAIN && ( =6.9.0'} requiresBuild: true dependencies: - '@babel/highlight': 7.25.7 + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 picocolors: 1.1.0 dev: true - optional: true /@babel/compat-data@7.18.8: resolution: {integrity: sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ==} @@ -315,6 +318,17 @@ packages: jsesc: 2.5.2 dev: true + /@babel/generator@7.26.2: + resolution: {integrity: sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/parser': 7.26.2 + '@babel/types': 7.26.0 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.0.2 + dev: true + /@babel/helper-annotate-as-pure@7.22.5: resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==} engines: {node: '>=6.9.0'} @@ -322,12 +336,14 @@ packages: '@babel/types': 7.23.6 dev: true - /@babel/helper-builder-binary-assignment-operator-visitor@7.18.9: - resolution: {integrity: sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==} + /@babel/helper-builder-binary-assignment-operator-visitor@7.25.9: + resolution: {integrity: sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-explode-assignable-expression': 7.18.6 - '@babel/types': 7.23.6 + '@babel/traverse': 7.25.9 + '@babel/types': 7.26.0 + transitivePeerDependencies: + - supports-color dev: true /@babel/helper-compilation-targets@7.18.9(@babel/core@7.18.9): @@ -339,7 +355,7 @@ packages: '@babel/compat-data': 7.18.8 '@babel/core': 7.18.9 '@babel/helper-validator-option': 7.18.6 - browserslist: 4.21.7 + browserslist: 4.24.2 semver: 6.3.0 dev: true @@ -380,7 +396,7 @@ packages: '@babel/core': 7.18.9 '@babel/helper-compilation-targets': 7.18.9(@babel/core@7.18.9) '@babel/helper-module-imports': 7.22.15 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 '@babel/traverse': 7.23.2 debug: 4.3.4(supports-color@8.1.1) lodash.debounce: 4.0.8 @@ -398,7 +414,7 @@ packages: '@babel/core': 7.18.9 '@babel/helper-compilation-targets': 7.18.9(@babel/core@7.18.9) '@babel/helper-module-imports': 7.22.15 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 '@babel/traverse': 7.23.2 debug: 4.3.4(supports-color@8.1.1) lodash.debounce: 4.0.8 @@ -413,13 +429,6 @@ packages: engines: {node: '>=6.9.0'} dev: true - /@babel/helper-explode-assignable-expression@7.18.6: - resolution: {integrity: sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.23.6 - dev: true - /@babel/helper-function-name@7.23.0: resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==} engines: {node: '>=6.9.0'} @@ -482,6 +491,11 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/helper-plugin-utils@7.25.9: + resolution: {integrity: sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-remap-async-to-generator@7.18.9(@babel/core@7.18.9): resolution: {integrity: sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==} engines: {node: '>=6.9.0'} @@ -536,17 +550,20 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/helper-string-parser@7.25.9: + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-validator-identifier@7.22.20: resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} dev: true - /@babel/helper-validator-identifier@7.25.7: - resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==} + /@babel/helper-validator-identifier@7.25.9: + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} engines: {node: '>=6.9.0'} - requiresBuild: true dev: true - optional: true /@babel/helper-validator-option@7.18.6: resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==} @@ -595,18 +612,6 @@ packages: js-tokens: 4.0.0 dev: true - /@babel/highlight@7.25.7: - resolution: {integrity: sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==} - engines: {node: '>=6.9.0'} - requiresBuild: true - dependencies: - '@babel/helper-validator-identifier': 7.25.7 - chalk: 2.4.2 - js-tokens: 4.0.0 - picocolors: 1.1.0 - dev: true - optional: true - /@babel/parser@7.23.0: resolution: {integrity: sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==} engines: {node: '>=6.0.0'} @@ -615,6 +620,14 @@ packages: '@babel/types': 7.23.6 dev: true + /@babel/parser@7.26.2: + resolution: {integrity: sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.26.0 + dev: true + /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.18.6(@babel/core@7.18.9): resolution: {integrity: sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==} engines: {node: '>=6.9.0'} @@ -689,7 +702,7 @@ packages: dependencies: '@babel/core': 7.18.9 '@babel/helper-create-class-features-plugin': 7.18.6(@babel/core@7.18.9) - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 '@babel/plugin-syntax-decorators': 7.23.3(@babel/core@7.18.9) transitivePeerDependencies: - supports-color @@ -864,7 +877,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.18.9 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 dev: true /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.18.9): @@ -920,7 +933,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.18.9 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 dev: true /@babel/plugin-syntax-import-assertions@7.18.6(@babel/core@7.18.9): @@ -939,7 +952,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.18.9 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 dev: true /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.18.9): @@ -1042,7 +1055,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.18.9 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 dev: true /@babel/plugin-transform-arrow-functions@7.18.6(@babel/core@7.18.9): @@ -1149,15 +1162,17 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-transform-exponentiation-operator@7.18.6(@babel/core@7.18.9): - resolution: {integrity: sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==} + /@babel/plugin-transform-exponentiation-operator@7.25.9(@babel/core@7.18.9): + resolution: {integrity: sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.18.9 - '@babel/helper-builder-binary-assignment-operator-visitor': 7.18.9 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-builder-binary-assignment-operator-visitor': 7.25.9 + '@babel/helper-plugin-utils': 7.25.9 + transitivePeerDependencies: + - supports-color dev: true /@babel/plugin-transform-flow-strip-types@7.13.0(@babel/core@7.18.9): @@ -1166,7 +1181,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.18.9 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 '@babel/plugin-syntax-flow': 7.12.13(@babel/core@7.18.9) dev: true @@ -1340,7 +1355,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.18.9 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 dev: true /@babel/plugin-transform-react-jsx-development@7.12.17(@babel/core@7.18.9): @@ -1373,7 +1388,7 @@ packages: dependencies: '@babel/core': 7.18.9 '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 dev: true /@babel/plugin-transform-regenerator@7.18.6(@babel/core@7.18.9): @@ -1404,7 +1419,7 @@ packages: dependencies: '@babel/core': 7.18.9 '@babel/helper-module-imports': 7.22.15 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 babel-plugin-polyfill-corejs2: 0.1.10(@babel/core@7.18.9) babel-plugin-polyfill-corejs3: 0.1.7(@babel/core@7.18.9) babel-plugin-polyfill-regenerator: 0.1.6(@babel/core@7.18.9) @@ -1551,7 +1566,7 @@ packages: '@babel/plugin-transform-destructuring': 7.18.9(@babel/core@7.18.9) '@babel/plugin-transform-dotall-regex': 7.18.6(@babel/core@7.18.9) '@babel/plugin-transform-duplicate-keys': 7.18.9(@babel/core@7.18.9) - '@babel/plugin-transform-exponentiation-operator': 7.18.6(@babel/core@7.18.9) + '@babel/plugin-transform-exponentiation-operator': 7.25.9(@babel/core@7.18.9) '@babel/plugin-transform-for-of': 7.18.8(@babel/core@7.18.9) '@babel/plugin-transform-function-name': 7.18.9(@babel/core@7.18.9) '@babel/plugin-transform-literals': 7.18.9(@babel/core@7.18.9) @@ -1591,7 +1606,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.18.9 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 '@babel/plugin-transform-flow-strip-types': 7.13.0(@babel/core@7.18.9) dev: true @@ -1614,7 +1629,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.18.9 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-plugin-utils': 7.25.7 '@babel/plugin-transform-react-display-name': 7.12.13(@babel/core@7.18.9) '@babel/plugin-transform-react-jsx': 7.23.4(@babel/core@7.18.9) '@babel/plugin-transform-react-jsx-development': 7.12.17(@babel/core@7.18.9) @@ -1650,6 +1665,15 @@ packages: '@babel/types': 7.23.6 dev: true + /@babel/template@7.25.9: + resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.26.2 + '@babel/types': 7.26.0 + dev: true + /@babel/traverse@7.23.2: resolution: {integrity: sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==} engines: {node: '>=6.9.0'} @@ -1668,6 +1692,21 @@ packages: - supports-color dev: true + /@babel/traverse@7.25.9: + resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.2 + '@babel/parser': 7.26.2 + '@babel/template': 7.25.9 + '@babel/types': 7.26.0 + debug: 4.3.4(supports-color@8.1.1) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/types@7.23.6: resolution: {integrity: sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==} engines: {node: '>=6.9.0'} @@ -1677,6 +1716,14 @@ packages: to-fast-properties: 2.0.0 dev: true + /@babel/types@7.26.0: + resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + dev: true + /@bcoe/v8-coverage@0.2.3: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true @@ -2459,6 +2506,15 @@ packages: '@jridgewell/trace-mapping': 0.3.20 dev: true + /@jridgewell/gen-mapping@0.3.5: + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + /@jridgewell/resolve-uri@3.1.1: resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} engines: {node: '>=6.0.0'} @@ -2469,6 +2525,11 @@ packages: engines: {node: '>=6.0.0'} dev: true + /@jridgewell/set-array@1.2.1: + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + dev: true + /@jridgewell/source-map@0.3.5: resolution: {integrity: sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==} dependencies: @@ -2491,6 +2552,13 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@jridgewell/trace-mapping@0.3.25: + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.5.0 + dev: true + /@jridgewell/trace-mapping@0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} dependencies: @@ -2498,8 +2566,8 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true - /@mdn/browser-compat-data@5.2.61: - resolution: {integrity: sha512-jzPrheqEtrnUWWNUS8SFVbQAnoO6rOXetkjiJyzP92UM+BNcyExLD0Qikv9z6TU9D6A9rbXkh3pSmZ5G88d6ew==} + /@mdn/browser-compat-data@5.6.16: + resolution: {integrity: sha512-48c0o53eGIwocPXgGwS4bAJpQ5YODPdOlus7g1F6vNP+fAv/isoVQA2Z7x+TfhftsapPYJXZVzp3hI4CjD1Dfg==} dev: true /@miherlosev/esm@3.2.26: @@ -2802,7 +2870,7 @@ packages: dev: true optional: true - /@rrweb/record@2.0.0-alpha.17(patch_hash=ds4wzvngpacxicd464fatv6v44): + /@rrweb/record@2.0.0-alpha.17(patch_hash=vxikte2wlhu3ltbso5lzopaskq): resolution: {integrity: sha512-Je+lzjeWMF8/I0IDoXFzkGPKT8j7AkaBup5YcwUHlkp18VhLVze416MvI6915teE27uUA2ScXMXzG0Yiu5VTIw==} dependencies: '@rrweb/types': 2.0.0-alpha.17 @@ -3343,14 +3411,6 @@ packages: deprecated: Use your platform's native atob() and btoa() methods instead dev: true - /accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - dev: true - /acorn-globals@6.0.0: resolution: {integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==} dependencies: @@ -3541,10 +3601,6 @@ packages: resolution: {integrity: sha512-kO/vVCacW9mnpn3WPWbTVlEnOabK2L7LWi2HViURtCM46y1zb6I8UMjx4LgbiqadTgHnLInUronwn3ampNTJtQ==} dev: true - /array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - dev: true - /array-includes@3.1.5: resolution: {integrity: sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ==} engines: {node: '>= 0.4'} @@ -3628,7 +3684,7 @@ packages: /ast-metadata-inferer@0.8.0: resolution: {integrity: sha512-jOMKcHht9LxYIEQu+RVd22vtgrPaVCtDRQ/16IGmurdzxvYbDd5ynxjnyrzLnieG96eTcAyaoj/wN/4/1FyyeA==} dependencies: - '@mdn/browser-compat-data': 5.2.61 + '@mdn/browser-compat-data': 5.6.16 dev: true /astral-regex@2.0.0: @@ -3940,26 +3996,6 @@ packages: resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} dev: true - /body-parser@1.20.2: - resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.11.0 - raw-body: 2.5.2 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - dev: true - /bowser@1.6.0: resolution: {integrity: sha512-Fk23J0+vRnI2eKDEDoUZXWtbMjijr098lKhuj4DKAfMKMCRVfJOuxXlbpxy0sTgbZ/Nr2N8MexmOir+GGI/ZMA==} dev: true @@ -4010,15 +4046,15 @@ packages: resolution: {integrity: sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==} dev: true - /browserslist@4.21.7: - resolution: {integrity: sha512-BauCXrQ7I2ftSqd2mvKHGo85XR0u7Ru3C/Hxsy/0TkfCtjrmAbPdzLGasmoiBxplpDXlPvdjX9u7srIMfgasNA==} + /browserslist@4.24.2: + resolution: {integrity: sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001580 - electron-to-chromium: 1.4.418 - node-releases: 2.0.12 - update-browserslist-db: 1.0.11(browserslist@4.21.7) + caniuse-lite: 1.0.30001680 + electron-to-chromium: 1.5.62 + node-releases: 2.0.18 + update-browserslist-db: 1.1.1(browserslist@4.24.2) dev: true /browserstack-local@1.5.1: @@ -4063,11 +4099,6 @@ packages: ieee754: 1.2.1 dev: true - /bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - dev: true - /cache-base@1.0.1: resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==} engines: {node: '>=0.10.0'} @@ -4127,8 +4158,8 @@ packages: engines: {node: '>=10'} dev: true - /caniuse-lite@1.0.30001580: - resolution: {integrity: sha512-mtj5ur2FFPZcCEpXFy8ADXbDACuNFXg6mxVDqp7tqooX6l3zwm+d8EPoeOSIFRDvHs8qu7/SLFOGniULkcH2iA==} + /caniuse-lite@1.0.30001680: + resolution: {integrity: sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==} dev: true /capture-exit@2.0.0: @@ -4415,43 +4446,17 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true - /content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - dependencies: - safe-buffer: 5.2.1 - dev: true - - /content-type@1.0.4: - resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==} - engines: {node: '>= 0.6'} - dev: true - - /content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - dev: true - /convert-source-map@1.7.0: resolution: {integrity: sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==} dependencies: safe-buffer: 5.1.2 dev: true - /cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - dev: true - /cookie@0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} engines: {node: '>= 0.6'} dev: true - /cookie@0.6.0: - resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} - engines: {node: '>= 0.6'} - dev: true - /copy-descriptor@0.1.1: resolution: {integrity: sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==} engines: {node: '>=0.10.0'} @@ -4460,7 +4465,7 @@ packages: /core-js-compat@3.23.5: resolution: {integrity: sha512-fHYozIFIxd+91IIbXJgWd/igXIc8Mf9is0fusswjnGIWVG96y2cwyUdlCkGOw6rMLHKAxg7xtCIVaHsyOUnJIg==} dependencies: - browserslist: 4.21.7 + browserslist: 4.24.2 semver: 7.0.0 dev: true @@ -4812,22 +4817,12 @@ packages: engines: {node: '>=0.4.0'} dev: true - /depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - dev: true - /desired-capabilities@0.1.0: resolution: {integrity: sha512-MNcwZi1elX2YXbTUfs1R6A6RD5Ns3loTljRBu6FGNHY3LPFLVHzTg2tNLlZEpWVZdHQ0cVeQRHrCBdrnW/n1wA==} dependencies: extend: 3.0.2 dev: true - /destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - dev: true - /detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -4910,12 +4905,8 @@ packages: safe-buffer: 5.2.1 dev: true - /ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - dev: true - - /electron-to-chromium@1.4.418: - resolution: {integrity: sha512-1KnpDTS9onwAfMzW50LcpNtyOkMyjd/OLoD2Kx/DDITZqgNYixY71XNszPHNxyQQ/Brh+FDcUnf4BaM041sdWg==} + /electron-to-chromium@1.5.62: + resolution: {integrity: sha512-t8c+zLmJHa9dJy96yBZRXGQYoiCEnHYgFwn1asvSPZSUdVxnB62A4RASd7k41ytG3ErFBA0TpHlKg9D9SQBmLg==} dev: true /elegant-spinner@1.0.1: @@ -4942,11 +4933,6 @@ packages: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: true - /encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} - dev: true - /end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} dependencies: @@ -5046,8 +5032,9 @@ packages: engines: {node: '>=6'} dev: true - /escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + /escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} dev: true /escape-string-regexp@1.0.5: @@ -5087,21 +5074,21 @@ packages: eslint: 8.57.0 dev: true - /eslint-plugin-compat@4.1.4(eslint@8.57.0): - resolution: {integrity: sha512-RxySWBmzfIROLFKgeJBJue2BU/6vM2KJWXWAUq+oW4QtrsZXRxbjgxmO1OfF3sHcRuuIenTS/wgo3GyUWZF24w==} - engines: {node: '>=14.x'} + /eslint-plugin-compat@6.0.1(eslint@8.57.0): + resolution: {integrity: sha512-0MeIEuoy8kWkOhW38kK8hU4vkb6l/VvyjpuYDymYOXmUY9NvTgyErF16lYuX+HPS5hkmym7lfA+XpYZiWYWmYA==} + engines: {node: '>=18.x'} peerDependencies: - eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 dependencies: - '@mdn/browser-compat-data': 5.2.61 - '@tsconfig/node14': 1.0.3 + '@mdn/browser-compat-data': 5.6.16 ast-metadata-inferer: 0.8.0 - browserslist: 4.21.7 - caniuse-lite: 1.0.30001580 + browserslist: 4.24.2 + caniuse-lite: 1.0.30001680 eslint: 8.57.0 find-up: 5.0.0 + globals: 15.12.0 lodash.memoize: 4.1.2 - semver: 7.3.8 + semver: 7.6.3 dev: true /eslint-plugin-jest@28.8.3(@typescript-eslint/eslint-plugin@8.2.0)(eslint@8.57.0)(jest@27.5.1)(typescript@5.5.4): @@ -5289,11 +5276,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - dev: true - /event-stream@3.3.4: resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} dependencies: @@ -5430,45 +5412,6 @@ packages: jest-util: 29.7.0 dev: true - /express@4.19.2: - resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==} - engines: {node: '>= 0.10.0'} - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.2 - content-disposition: 0.5.4 - content-type: 1.0.4 - cookie: 0.6.0 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.2.0 - fresh: 0.5.2 - http-errors: 2.0.0 - merge-descriptors: 1.0.1 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.7 - proxy-addr: 2.0.7 - qs: 6.11.0 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.18.0 - serve-static: 1.15.0 - setprototypeof: 1.2.0 - statuses: 2.0.1 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - dev: true - /extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -5640,21 +5583,6 @@ packages: to-regex-range: 5.0.1 dev: true - /finalhandler@1.2.0: - resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} - engines: {node: '>= 0.8'} - dependencies: - debug: 2.6.9 - encodeurl: 1.0.2 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.1 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - dev: true - /find-babel-config@1.2.0: resolution: {integrity: sha512-jB2CHJeqy6a820ssiqwrKMeyC6nNdmrcgkKWJWmpoxpE8RKciYJXCcXRq1h2AzCo5I5BJeN2tkGEO3hLTuePRA==} engines: {node: '>=4.0.0'} @@ -5742,11 +5670,6 @@ packages: mime-types: 2.1.35 dev: true - /forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - dev: true - /fp-ts@2.16.2: resolution: {integrity: sha512-CkqAjnIKFqvo3sCyoBTqgJvF+bHrSik584S9nhTjtBESLx26cbtVMR/T9a6ApChOcSDAaM3JydDmWDUn4EEXng==} dev: true @@ -5758,11 +5681,6 @@ packages: map-cache: 0.2.2 dev: true - /fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - dev: true - /from@0.1.7: resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} dev: true @@ -5965,6 +5883,11 @@ packages: type-fest: 0.20.2 dev: true + /globals@15.12.0: + resolution: {integrity: sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==} + engines: {node: '>=18'} + dev: true + /globby@10.0.2: resolution: {integrity: sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==} engines: {node: '>=8'} @@ -6151,17 +6074,6 @@ packages: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} dev: true - /http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} - engines: {node: '>= 0.8'} - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.1 - toidentifier: 1.0.1 - dev: true - /http-proxy-agent@4.0.1: resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} engines: {node: '>= 6'} @@ -6368,11 +6280,6 @@ packages: resolution: {integrity: sha512-rBtCAQAJm8A110nbwn6YdveUnuZH3WrC36IwkRXxDnq53JvXA2NVQvB7IHyKomxK1MJ4VDNw3UtFDdXQ+AvLYA==} dev: true - /ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - dev: true - /is-accessor-descriptor@0.1.6: resolution: {integrity: sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==} engines: {node: '>=0.10.0'} @@ -7597,6 +7504,12 @@ packages: hasBin: true dev: true + /jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + dev: true + /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true @@ -8018,15 +7931,6 @@ packages: escape-string-regexp: 1.0.5 dev: true - /media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - dev: true - - /merge-descriptors@1.0.1: - resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} - dev: true - /merge-stream@1.0.1: resolution: {integrity: sha512-e6RM36aegd4f+r8BZCcYXlO2P3H6xbUM6ktL2Xmf45GAOit9bI4z6/3VU7JwllVO1L7u0UDSg/EhzQ5lmMLolA==} dependencies: @@ -8042,11 +7946,6 @@ packages: engines: {node: '>= 8'} dev: true - /methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - dev: true - /micromatch@3.1.10: resolution: {integrity: sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==} engines: {node: '>=0.10.0'} @@ -8265,11 +8164,6 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - dev: true - /newtype-ts@0.3.5(fp-ts@2.16.2)(monocle-ts@2.3.13): resolution: {integrity: sha512-v83UEQMlVR75yf1OUdoSFssjitxzjZlqBAjiGQ4WJaML8Jdc68LJ+BaSAXUmKY4bNzp7hygkKLYTsDi14PxI2g==} peerDependencies: @@ -8310,8 +8204,8 @@ packages: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} dev: true - /node-releases@2.0.12: - resolution: {integrity: sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==} + /node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} dev: true /normalize-path@2.1.1: @@ -8441,13 +8335,6 @@ packages: resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} dev: true - /on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - dependencies: - ee-first: 1.1.1 - dev: true - /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -8636,7 +8523,7 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} dependencies: - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.26.2 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.1.6 @@ -8654,11 +8541,6 @@ packages: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} dev: true - /parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - dev: true - /pascalcase@0.1.1: resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==} engines: {node: '>=0.10.0'} @@ -8697,10 +8579,6 @@ packages: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true - /path-to-regexp@0.1.7: - resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} - dev: true - /path-to-regexp@1.8.0: resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==} dependencies: @@ -8935,14 +8813,6 @@ packages: react-is: 16.13.1 dev: true - /proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - dev: true - /proxy-from-env@1.0.0: resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} dev: true @@ -8987,13 +8857,6 @@ packages: side-channel: 1.0.4 dev: true - /qs@6.11.0: - resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} - engines: {node: '>=0.6'} - dependencies: - side-channel: 1.0.4 - dev: true - /qs@6.5.3: resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} engines: {node: '>=0.6'} @@ -9013,21 +8876,6 @@ packages: safe-buffer: 5.2.1 dev: true - /range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - dev: true - - /raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - dev: true - /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true @@ -9363,7 +9211,7 @@ packages: rollup: 4.24.0 typescript: 5.5.4 optionalDependencies: - '@babel/code-frame': 7.25.7 + '@babel/code-frame': 7.26.2 dev: true /rollup-plugin-visualizer@5.12.0(rollup@4.24.0): @@ -9541,14 +9389,6 @@ packages: hasBin: true dev: true - /semver@7.3.8: - resolution: {integrity: sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==} - engines: {node: '>=10'} - hasBin: true - dependencies: - lru-cache: 6.0.0 - dev: true - /semver@7.5.4: resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} engines: {node: '>=10'} @@ -9563,45 +9403,12 @@ packages: hasBin: true dev: true - /send@0.18.0: - resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} - engines: {node: '>= 0.8.0'} - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.0 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.1 - transitivePeerDependencies: - - supports-color - dev: true - /serialize-javascript@6.0.1: resolution: {integrity: sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==} dependencies: randombytes: 2.1.0 dev: true - /serve-static@1.15.0: - resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} - engines: {node: '>= 0.8.0'} - dependencies: - encodeurl: 1.0.2 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.18.0 - transitivePeerDependencies: - - supports-color - dev: true - /set-cookie-parser@2.6.0: resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} dev: true @@ -9616,10 +9423,6 @@ packages: split-string: 3.1.0 dev: true - /setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - dev: true - /shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -9833,11 +9636,6 @@ packages: object-copy: 0.1.0 dev: true - /statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} - engines: {node: '>= 0.8'} - dev: true - /stealthy-require@1.1.1: resolution: {integrity: sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==} engines: {node: '>=0.10.0'} @@ -10207,7 +10005,7 @@ packages: '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.18.9) '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.18.9) '@babel/plugin-transform-async-to-generator': 7.18.6(@babel/core@7.18.9) - '@babel/plugin-transform-exponentiation-operator': 7.18.6(@babel/core@7.18.9) + '@babel/plugin-transform-exponentiation-operator': 7.25.9(@babel/core@7.18.9) '@babel/plugin-transform-for-of': 7.18.8(@babel/core@7.18.9) '@babel/plugin-transform-runtime': 7.13.9(@babel/core@7.18.9) '@babel/preset-env': 7.18.9(@babel/core@7.18.9) @@ -10408,11 +10206,6 @@ packages: safe-regex: 1.1.0 dev: true - /toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - dev: true - /tough-cookie@2.5.0: resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} engines: {node: '>=0.8'} @@ -10558,14 +10351,6 @@ packages: engines: {node: '>=12.20'} dev: true - /type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - dev: true - /typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} dependencies: @@ -10650,11 +10435,6 @@ packages: engines: {node: '>= 10.0.0'} dev: true - /unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - dev: true - /unquote@1.1.1: resolution: {integrity: sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==} dev: true @@ -10672,14 +10452,14 @@ packages: engines: {node: '>=8'} dev: true - /update-browserslist-db@1.0.11(browserslist@4.21.7): - resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} + /update-browserslist-db@1.1.1(browserslist@4.24.2): + resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' dependencies: - browserslist: 4.21.7 - escalade: 3.1.1 + browserslist: 4.24.2 + escalade: 3.2.0 picocolors: 1.1.0 dev: true @@ -10730,11 +10510,6 @@ packages: which-typed-array: 1.1.9 dev: true - /utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - dev: true - /uuid@3.3.3: resolution: {integrity: sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==} deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. @@ -10765,11 +10540,6 @@ packages: source-map: 0.7.4 dev: true - /vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - dev: true - /verror@1.10.0: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} diff --git a/react/src/components/PostHogFeature.tsx b/react/src/components/PostHogFeature.tsx index d0fbf162d..fa30a8e30 100644 --- a/react/src/components/PostHogFeature.tsx +++ b/react/src/components/PostHogFeature.tsx @@ -1,6 +1,7 @@ import { useFeatureFlagPayload, useFeatureFlagVariantKey, usePostHog } from '../hooks' -import React, { useCallback, useEffect, useRef } from 'react' +import React, { Children, ReactNode, useCallback, useEffect, useRef } from 'react' import { PostHog } from '../context' +import { isFunction, isNull, isUndefined } from '../utils/type-utils' export type PostHogFeatureProps = React.HTMLProps & { flag: string @@ -28,10 +29,10 @@ export function PostHogFeature({ const shouldTrackInteraction = trackInteraction ?? true const shouldTrackView = trackView ?? true - if (match === undefined || variant === match) { - const childNode: React.ReactNode = typeof children === 'function' ? children(payload) : children + if (isUndefined(match) || variant === match) { + const childNode: React.ReactNode = isFunction(children) ? children(payload) : children return ( - {childNode} - + ) } return <>{fallback} @@ -56,38 +57,24 @@ function captureFeatureView(flag: string, posthog: PostHog) { function VisibilityAndClickTracker({ flag, children, - trackInteraction, + onIntersect, + onClick, trackView, options, ...props }: { flag: string children: React.ReactNode - trackInteraction: boolean + onIntersect: (entry: IntersectionObserverEntry) => void + onClick: () => void trackView: boolean options?: IntersectionObserverInit }): JSX.Element { const ref = useRef(null) const posthog = usePostHog() - const visibilityTrackedRef = useRef(false) - const clickTrackedRef = useRef(false) - - const cachedOnClick = useCallback(() => { - if (!clickTrackedRef.current && trackInteraction) { - captureFeatureInteraction(flag, posthog) - clickTrackedRef.current = true - } - }, [flag, posthog, trackInteraction]) useEffect(() => { - if (ref.current === null || !trackView) return - - const onIntersect = (entry: IntersectionObserverEntry) => { - if (!visibilityTrackedRef.current && entry.isIntersecting) { - captureFeatureView(flag, posthog) - visibilityTrackedRef.current = true - } - } + if (isNull(ref.current) || !trackView) return // eslint-disable-next-line compat/compat const observer = new IntersectionObserver(([entry]) => onIntersect(entry), { @@ -96,11 +83,61 @@ function VisibilityAndClickTracker({ }) observer.observe(ref.current) return () => observer.disconnect() - }, [flag, options, posthog, ref, trackView]) + }, [flag, options, posthog, ref, trackView, onIntersect]) return ( -
+
{children}
) } + +function VisibilityAndClickTrackers({ + flag, + children, + trackInteraction, + trackView, + options, + ...props +}: { + flag: string + children: React.ReactNode + trackInteraction: boolean + trackView: boolean + options?: IntersectionObserverInit +}): JSX.Element { + const clickTrackedRef = useRef(false) + const visibilityTrackedRef = useRef(false) + const posthog = usePostHog() + + const cachedOnClick = useCallback(() => { + if (!clickTrackedRef.current && trackInteraction) { + captureFeatureInteraction(flag, posthog) + clickTrackedRef.current = true + } + }, [flag, posthog, trackInteraction]) + + const onIntersect = (entry: IntersectionObserverEntry) => { + if (!visibilityTrackedRef.current && entry.isIntersecting) { + captureFeatureView(flag, posthog) + visibilityTrackedRef.current = true + } + } + + const trackedChildren = Children.map(children, (child: ReactNode) => { + return ( + + {child} + + ) + }) + + return <>{trackedChildren} +} diff --git a/react/src/components/__tests__/PostHogFeature.test.jsx b/react/src/components/__tests__/PostHogFeature.test.jsx index ba6ad0369..8bc27afbc 100644 --- a/react/src/components/__tests__/PostHogFeature.test.jsx +++ b/react/src/components/__tests__/PostHogFeature.test.jsx @@ -1,7 +1,7 @@ import * as React from 'react' -import { useState } from 'react'; +import { useState } from 'react' import { render, screen, fireEvent } from '@testing-library/react' -import { PostHogContext, PostHogProvider } from '../../context' +import { PostHogProvider } from '../../context' import { PostHogFeature } from '../' import '@testing-library/jest-dom' @@ -89,8 +89,34 @@ describe('PostHogFeature component', () => { expect(given.posthog.capture).toHaveBeenCalledTimes(1) }) + it('should track an interaction with each child node of the feature component', () => { + given( + 'render', + () => () => + render( + + +
Hello
+
World!
+
+
+ ) + ) + given.render() + + fireEvent.click(screen.getByTestId('helloDiv')) + fireEvent.click(screen.getByTestId('helloDiv')) + fireEvent.click(screen.getByTestId('worldDiv')) + fireEvent.click(screen.getByTestId('worldDiv')) + fireEvent.click(screen.getByTestId('worldDiv')) + expect(given.posthog.capture).toHaveBeenCalledWith('$feature_interaction', { + feature_flag: 'test', + $set: { '$feature_interaction/test': true }, + }) + expect(given.posthog.capture).toHaveBeenCalledTimes(1) + }) + it('should not fire events when interaction is disabled', () => { - given( 'render', () => () => @@ -114,14 +140,24 @@ describe('PostHogFeature component', () => { }) it('should fire events when interaction is disabled but re-enabled after', () => { - const DynamicUpdateComponent = () => { const [trackInteraction, setTrackInteraction] = useState(false) return ( <> -
{setTrackInteraction(true)}}>Click me
- +
{ + setTrackInteraction(true) + }} + > + Click me +
+
Hello
diff --git a/react/src/utils/type-utils.ts b/react/src/utils/type-utils.ts new file mode 100644 index 000000000..a61194526 --- /dev/null +++ b/react/src/utils/type-utils.ts @@ -0,0 +1,14 @@ +// from a comment on http://dbj.org/dbj/?p=286 +// fails on only one very rare and deliberate custom object: +// let bomb = { toString : undefined, valueOf: function(o) { return "function BOMBA!"; }}; +export const isFunction = function (f: any): f is (...args: any[]) => any { + // eslint-disable-next-line posthog-js/no-direct-function-check + return typeof f === 'function' +} +export const isUndefined = function (x: unknown): x is undefined { + return x === void 0 +} +export const isNull = function (x: unknown): x is null { + // eslint-disable-next-line posthog-js/no-direct-null-check + return x === null +} diff --git a/rollup.config.js b/rollup.config.js index 648cdd8bb..f947d51c8 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -17,14 +17,33 @@ const plugins = (es5) => [ babel({ extensions: ['.js', '.jsx', '.ts', '.tsx'], babelHelpers: 'bundled', - plugins: ['@babel/plugin-transform-nullish-coalescing-operator'], + plugins: [ + '@babel/plugin-transform-nullish-coalescing-operator', + // Explicitly included so we transform 1 ** 2 to Math.pow(1, 2) for ES6 compatability + '@babel/plugin-transform-exponentiation-operator', + ], presets: [ [ '@babel/preset-env', { targets: es5 - ? '>0.5%, last 2 versions, Firefox ESR, not dead, IE 11' - : '>0.5%, last 2 versions, Firefox ESR, not dead', + ? [ + '> 0.5%, last 2 versions, Firefox ESR, not dead', + 'chrome > 62', + 'firefox > 59', + 'ios_saf >= 6.1', + 'opera > 50', + 'safari > 12', + 'IE 11', + ] + : [ + '> 0.5%, last 2 versions, Firefox ESR, not dead', + 'chrome > 62', + 'firefox > 59', + 'ios_saf >= 10.3', + 'opera > 50', + 'safari > 12', + ], }, ], ], diff --git a/src/__tests__/autocapture.test.ts b/src/__tests__/autocapture.test.ts index 25d420fa2..1cf4ebb2d 100644 --- a/src/__tests__/autocapture.test.ts +++ b/src/__tests__/autocapture.test.ts @@ -63,7 +63,7 @@ describe('Autocapture system', () => { let autocapture: Autocapture let posthog: PostHog - let captureMock: jest.Mock + let beforeSendMock: jest.Mock beforeEach(async () => { jest.spyOn(window!.console, 'log').mockImplementation() @@ -76,13 +76,13 @@ describe('Autocapture system', () => { value: new URL('https://example.com'), }) - captureMock = jest.fn() + beforeSendMock = jest.fn().mockImplementation((...args) => args) posthog = await createPosthogInstance(uuidv7(), { api_host: 'https://test.com', token: 'testtoken', autocapture: true, - _onCapture: captureMock, + before_send: beforeSendMock, }) if (isUndefined(posthog.autocapture)) { @@ -400,8 +400,7 @@ describe('Autocapture system', () => { autocapture['_captureEvent'](fakeEvent) autocapture['_captureEvent'](fakeEvent) - expect(captureMock).toHaveBeenCalledTimes(4) - expect(captureMock.mock.calls.map((args) => args[0])).toEqual([ + expect(beforeSendMock.mock.calls.map((args) => args[0].event)).toEqual([ '$autocapture', '$autocapture', '$rageclick', @@ -428,10 +427,11 @@ describe('Autocapture system', () => { autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - expect(captureMock).toHaveBeenCalledTimes(1) - expect(captureMock.mock.calls[0][0]).toEqual('$copy_autocapture') - expect(captureMock.mock.calls[0][1].properties).toHaveProperty('$selected_content', 'copy this test') - expect(captureMock.mock.calls[0][1].properties).toHaveProperty('$copy_type', 'copy') + expect(beforeSendMock).toHaveBeenCalledTimes(1) + const mockCall = beforeSendMock.mock.calls[0][0] + expect(mockCall.event).toEqual('$copy_autocapture') + expect(mockCall.properties).toHaveProperty('$selected_content', 'copy this test') + expect(mockCall.properties).toHaveProperty('$copy_type', 'copy') }) it('should capture cut', () => { @@ -443,11 +443,11 @@ describe('Autocapture system', () => { autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - const spyArgs = captureMock.mock.calls + const spyArgs = beforeSendMock.mock.calls expect(spyArgs.length).toBe(1) - expect(spyArgs[0][0]).toEqual('$copy_autocapture') - expect(spyArgs[0][1].properties).toHaveProperty('$selected_content', 'cut this test') - expect(spyArgs[0][1].properties).toHaveProperty('$copy_type', 'cut') + expect(spyArgs[0][0].event).toEqual('$copy_autocapture') + expect(spyArgs[0][0].properties).toHaveProperty('$selected_content', 'cut this test') + expect(spyArgs[0][0].properties).toHaveProperty('$copy_type', 'cut') }) it('ignores empty selection', () => { @@ -459,7 +459,7 @@ describe('Autocapture system', () => { autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - const spyArgs = captureMock.mock.calls + const spyArgs = beforeSendMock.mock.calls expect(spyArgs.length).toBe(0) }) @@ -473,7 +473,7 @@ describe('Autocapture system', () => { autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - const spyArgs = captureMock.mock.calls + const spyArgs = beforeSendMock.mock.calls expect(spyArgs.length).toBe(0) }) }) @@ -495,7 +495,7 @@ describe('Autocapture system', () => { Object.setPrototypeOf(fakeEvent, MouseEvent.prototype) autocapture['_captureEvent'](fakeEvent) - const captureProperties = captureMock.mock.calls[0][1].properties + const captureProperties = beforeSendMock.mock.calls[0][0].properties expect(captureProperties).toHaveProperty('target-augment', 'the target') expect(captureProperties).toHaveProperty('parent-augment', 'the parent') }) @@ -517,10 +517,10 @@ describe('Autocapture system', () => { document.body.appendChild(eventElement2) document.body.appendChild(propertyElement) - expect(captureMock).toHaveBeenCalledTimes(0) + expect(beforeSendMock).toHaveBeenCalledTimes(0) simulateClick(eventElement1) simulateClick(eventElement2) - expect(captureMock).toHaveBeenCalledTimes(0) + expect(beforeSendMock).toHaveBeenCalledTimes(0) }) it('should not capture events when config returns true but server setting is disabled', () => { @@ -531,9 +531,9 @@ describe('Autocapture system', () => { const eventElement = document.createElement('a') document.body.appendChild(eventElement) - expect(captureMock).toHaveBeenCalledTimes(0) + expect(beforeSendMock).toHaveBeenCalledTimes(0) simulateClick(eventElement) - expect(captureMock).toHaveBeenCalledTimes(0) + expect(beforeSendMock).toHaveBeenCalledTimes(0) }) it('includes necessary metadata as properties when capturing an event', () => { @@ -550,10 +550,10 @@ describe('Autocapture system', () => { target: elTarget, }) autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const captureArgs = captureMock.mock.calls[0] - const event = captureArgs[0] - const props = captureArgs[1].properties + expect(beforeSendMock).toHaveBeenCalledTimes(1) + const captureArgs = beforeSendMock.mock.calls[0] + const event = captureArgs[0].event + const props = captureArgs[0].properties expect(event).toBe('$autocapture') expect(props['$event_type']).toBe('click') expect(props['$elements'][0]).toHaveProperty('attr__href', 'https://test.com') @@ -579,9 +579,9 @@ describe('Autocapture system', () => { target: elTarget, }) autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const captureArgs = captureMock.mock.calls[0] - const props = captureArgs[1].properties + expect(beforeSendMock).toHaveBeenCalledTimes(1) + const captureArgs = beforeSendMock.mock.calls[0] + const props = captureArgs[0].properties expect(longString).toBe('prop'.repeat(400)) expect(props['$elements'][0]).toHaveProperty('attr__data-props', 'prop'.repeat(256) + '...') }) @@ -606,7 +606,7 @@ describe('Autocapture system', () => { }) ) - const props = captureMock.mock.calls[0][1].properties + const props = beforeSendMock.mock.calls[0][0].properties expect(props['$element_selectors']).toContain('#primary_button') expect(props['$elements'][0]).toHaveProperty('attr__href', 'https://test.com') expect(props['$external_click_url']).toEqual('https://test.com') @@ -624,7 +624,7 @@ describe('Autocapture system', () => { target: elTarget, }) ) - const props = captureMock.mock.calls[0][1].properties + const props = beforeSendMock.mock.calls[0][0].properties expect(props['$elements'][0]).toHaveProperty('attr__href', 'https://test.com') expect(props['$external_click_url']).toEqual('https://test.com') }) @@ -642,7 +642,7 @@ describe('Autocapture system', () => { target: elTarget, }) ) - const props = captureMock.mock.calls[0][1].properties + const props = beforeSendMock.mock.calls[0][0].properties expect(props['$elements'][0]).toHaveProperty('attr__href', 'https://www.example.com/link') expect(props['$external_click_url']).toBeUndefined() }) @@ -659,7 +659,7 @@ describe('Autocapture system', () => { target: elTarget, }) ) - expect(captureMock.mock.calls[0][1].properties).not.toHaveProperty('attr__href') + expect(beforeSendMock.mock.calls[0][0].properties).not.toHaveProperty('attr__href') }) it('does not capture href attribute values from hidden elements', () => { @@ -674,7 +674,7 @@ describe('Autocapture system', () => { target: elTarget, }) ) - expect(captureMock.mock.calls[0][1].properties['$elements'][0]).not.toHaveProperty('attr__href') + expect(beforeSendMock.mock.calls[0][0].properties['$elements'][0]).not.toHaveProperty('attr__href') }) it('does not capture href attribute values that look like credit card numbers', () => { @@ -689,7 +689,7 @@ describe('Autocapture system', () => { target: elTarget, }) ) - expect(captureMock.mock.calls[0][1].properties['$elements'][0]).not.toHaveProperty('attr__href') + expect(beforeSendMock.mock.calls[0][0].properties['$elements'][0]).not.toHaveProperty('attr__href') }) it('does not capture href attribute values that look like social-security numbers', () => { @@ -704,7 +704,7 @@ describe('Autocapture system', () => { target: elTarget, }) ) - expect(captureMock.mock.calls[0][1].properties['$elements'][0]).not.toHaveProperty('attr__href') + expect(beforeSendMock.mock.calls[0][0].properties['$elements'][0]).not.toHaveProperty('attr__href') }) it('correctly identifies and formats text content', () => { @@ -745,10 +745,10 @@ describe('Autocapture system', () => { const e1 = makeMouseEvent({ target: span2, }) - captureMock.mockClear() + beforeSendMock.mockClear() autocapture['_captureEvent'](e1) - const props1 = captureMock.mock.calls[0][1].properties + const props1 = beforeSendMock.mock.calls[0][0].properties const text1 = "Some super duper really long Text with new lines that we'll strip out and also we will want to make this text shorter since it's not likely people really care about text content that's super long and it also takes up more space and bandwidth. Some super d" expect(props1['$elements'][0]).toHaveProperty('$el_text', text1) @@ -757,18 +757,18 @@ describe('Autocapture system', () => { const e2 = makeMouseEvent({ target: span1, }) - captureMock.mockClear() + beforeSendMock.mockClear() autocapture['_captureEvent'](e2) - const props2 = captureMock.mock.calls[0][1].properties + const props2 = beforeSendMock.mock.calls[0][0].properties expect(props2['$elements'][0]).toHaveProperty('$el_text', 'Some text') expect(props2['$el_text']).toEqual('Some text') const e3 = makeMouseEvent({ target: img2, }) - captureMock.mockClear() + beforeSendMock.mockClear() autocapture['_captureEvent'](e3) - const props3 = captureMock.mock.calls[0][1].properties + const props3 = beforeSendMock.mock.calls[0][0].properties expect(props3['$elements'][0]).toHaveProperty('$el_text', '') expect(props3).not.toHaveProperty('$el_text') }) @@ -796,7 +796,7 @@ describe('Autocapture system', () => { target: button1, }) autocapture['_captureEvent'](e1) - const props1 = captureMock.mock.calls[0][1].properties + const props1 = beforeSendMock.mock.calls[0][0].properties expect(props1['$elements'][0]).toHaveProperty('$el_text') expect(props1['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) @@ -804,7 +804,7 @@ describe('Autocapture system', () => { target: button2, }) autocapture['_captureEvent'](e2) - const props2 = captureMock.mock.calls[0][1].properties + const props2 = beforeSendMock.mock.calls[0][0].properties expect(props2['$elements'][0]).toHaveProperty('$el_text') expect(props2['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) @@ -812,7 +812,7 @@ describe('Autocapture system', () => { target: button3, }) autocapture['_captureEvent'](e3) - const props3 = captureMock.mock.calls[0][1].properties + const props3 = beforeSendMock.mock.calls[0][0].properties expect(props3['$elements'][0]).toHaveProperty('$el_text') expect(props3['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) }) @@ -823,8 +823,8 @@ describe('Autocapture system', () => { type: 'submit', } as unknown as FormDataEvent autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const props = captureMock.mock.calls[0][1].properties + expect(beforeSendMock).toHaveBeenCalledTimes(1) + const props = beforeSendMock.mock.calls[0][0].properties expect(props['$event_type']).toBe('submit') }) @@ -840,8 +840,8 @@ describe('Autocapture system', () => { target: link, }) autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const props = captureMock.mock.calls[0][1].properties + expect(beforeSendMock).toHaveBeenCalledTimes(1) + const props = beforeSendMock.mock.calls[0][0].properties expect(props['$event_type']).toBe('click') }) @@ -856,8 +856,8 @@ describe('Autocapture system', () => { composedPath: () => [button, main_el], }) autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const props = captureMock.mock.calls[0][1].properties + expect(beforeSendMock).toHaveBeenCalledTimes(1) + const props = beforeSendMock.mock.calls[0][0].properties expect(props['$event_type']).toBe('click') }) @@ -866,18 +866,18 @@ describe('Autocapture system', () => { const span = document.createElement('span') a.appendChild(span) autocapture['_captureEvent'](makeMouseEvent({ target: a })) - expect(captureMock).toHaveBeenCalledTimes(1) + expect(beforeSendMock).toHaveBeenCalledTimes(1) autocapture['_captureEvent'](makeMouseEvent({ target: span })) - expect(captureMock).toHaveBeenCalledTimes(2) + expect(beforeSendMock).toHaveBeenCalledTimes(2) - captureMock.mockClear() + beforeSendMock.mockClear() a.className = 'test1 ph-no-capture test2' autocapture['_captureEvent'](makeMouseEvent({ target: a })) - expect(captureMock).toHaveBeenCalledTimes(0) + expect(beforeSendMock).toHaveBeenCalledTimes(0) autocapture['_captureEvent'](makeMouseEvent({ target: span })) - expect(captureMock).toHaveBeenCalledTimes(0) + expect(beforeSendMock).toHaveBeenCalledTimes(0) }) it('does not capture any element attributes if mask_all_element_attributes is set', () => { @@ -897,7 +897,7 @@ describe('Autocapture system', () => { }) autocapture['_captureEvent'](e1) - const props1 = captureMock.mock.calls[0][1].properties + const props1 = beforeSendMock.mock.calls[0][0].properties expect('attr__formmethod' in props1['$elements'][0]).toEqual(false) }) @@ -916,7 +916,7 @@ describe('Autocapture system', () => { }) autocapture['_captureEvent'](e1) - const props1 = captureMock.mock.calls[0][1].properties + const props1 = beforeSendMock.mock.calls[0][0].properties expect(props1['$elements'][0]).not.toHaveProperty('$el_text') }) @@ -937,7 +937,7 @@ describe('Autocapture system', () => { } as DecideResponse) autocapture['_captureEvent'](e) - const props1 = captureMock.mock.calls[0][1].properties + const props1 = beforeSendMock.mock.calls[0][0].properties expect(props1['$elements_chain']).toBeDefined() expect(props1['$elements']).toBeUndefined() @@ -960,7 +960,7 @@ describe('Autocapture system', () => { autocapture['_elementsChainAsString'] = true autocapture['_captureEvent'](e) - const props1 = captureMock.mock.calls[0][1].properties + const props1 = beforeSendMock.mock.calls[0][0].properties expect(props1['$elements_chain']).toBe( 'a.test-class.test-class2.test-class3.test-class4.test-class5:nth-child="1"nth-of-type="1"href="http://test.com"attr__href="http://test.com"attr__class="test-class test-class2 test-class3 test-class4 test-class5";span:nth-child="1"nth-of-type="1"' @@ -991,8 +991,8 @@ describe('Autocapture system', () => { autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const props = captureMock.mock.calls[0][1].properties + expect(beforeSendMock).toHaveBeenCalledTimes(1) + const props = beforeSendMock.mock.calls[0][0].properties const capturedButton = props['$elements'][1] expect(capturedButton['tag_name']).toBe('button') expect(capturedButton['$el_text']).toBe('the button text with more info') @@ -1011,11 +1011,11 @@ describe('Autocapture system', () => { document.body.appendChild(button) simulateClick(button) simulateClick(button) - expect(captureMock).toHaveBeenCalledTimes(2) - expect(captureMock.mock.calls[0][0]).toBe('$autocapture') - expect(captureMock.mock.calls[0][1].properties['$event_type']).toBe('click') - expect(captureMock.mock.calls[1][0]).toBe('$autocapture') - expect(captureMock.mock.calls[1][1].properties['$event_type']).toBe('click') + expect(beforeSendMock).toHaveBeenCalledTimes(2) + expect(beforeSendMock.mock.calls[0][0].event).toBe('$autocapture') + expect(beforeSendMock.mock.calls[0][0].properties['$event_type']).toBe('click') + expect(beforeSendMock.mock.calls[1][0].event).toBe('$autocapture') + expect(beforeSendMock.mock.calls[1][0].properties['$event_type']).toBe('click') }) }) diff --git a/src/__tests__/consent.test.ts b/src/__tests__/consent.test.ts index c9483ac4a..b6f520ee5 100644 --- a/src/__tests__/consent.test.ts +++ b/src/__tests__/consent.test.ts @@ -81,15 +81,15 @@ describe('consentManager', () => { }) describe('opt out event', () => { - let onCapture = jest.fn() + let beforeSendMock = jest.fn().mockImplementation((...args) => args) beforeEach(() => { - onCapture = jest.fn() - posthog = createPostHog({ opt_out_capturing_by_default: true, _onCapture: onCapture }) + beforeSendMock = jest.fn().mockImplementation((e) => e) + posthog = createPostHog({ opt_out_capturing_by_default: true, before_send: beforeSendMock }) }) it('should send opt in event if not disabled', () => { posthog.opt_in_capturing() - expect(onCapture).toHaveBeenCalledWith('$opt_in', expect.objectContaining({})) + expect(beforeSendMock).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' })) }) it('should send opt in event with overrides', () => { @@ -99,9 +99,9 @@ describe('consentManager', () => { foo: 'bar', }, }) - expect(onCapture).toHaveBeenCalledWith( - 'override-opt-in', + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ + event: 'override-opt-in', properties: expect.objectContaining({ foo: 'bar', }), @@ -111,72 +111,72 @@ describe('consentManager', () => { it('should not send opt in event if false', () => { posthog.opt_in_capturing({ captureEventName: false }) - expect(onCapture).toHaveBeenCalledTimes(1) - expect(onCapture).not.toHaveBeenCalledWith('$opt_in') - expect(onCapture).lastCalledWith('$pageview', expect.anything()) + expect(beforeSendMock).toHaveBeenCalledTimes(1) + expect(beforeSendMock).not.toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' })) + expect(beforeSendMock).lastCalledWith(expect.objectContaining({ event: '$pageview' })) }) it('should not send opt in event if false', () => { posthog.opt_in_capturing({ captureEventName: false }) - expect(onCapture).toHaveBeenCalledTimes(1) - expect(onCapture).not.toHaveBeenCalledWith('$opt_in') - expect(onCapture).lastCalledWith('$pageview', expect.anything()) + expect(beforeSendMock).toHaveBeenCalledTimes(1) + expect(beforeSendMock).not.toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' })) + expect(beforeSendMock).lastCalledWith(expect.objectContaining({ event: '$pageview' })) }) it('should not send $pageview on opt in if capturing is disabled', () => { posthog = createPostHog({ opt_out_capturing_by_default: true, - _onCapture: onCapture, + before_send: beforeSendMock, capture_pageview: false, }) posthog.opt_in_capturing({ captureEventName: false }) - expect(onCapture).toHaveBeenCalledTimes(0) + expect(beforeSendMock).toHaveBeenCalledTimes(0) }) it('should not send $pageview on opt in if is has already been captured', async () => { posthog = createPostHog({ - _onCapture: onCapture, + before_send: beforeSendMock, }) // Wait for the initial $pageview to be captured // eslint-disable-next-line compat/compat await new Promise((r) => setTimeout(r, 10)) - expect(onCapture).toHaveBeenCalledTimes(1) - expect(onCapture).lastCalledWith('$pageview', expect.anything()) + expect(beforeSendMock).toHaveBeenCalledTimes(1) + expect(beforeSendMock).lastCalledWith(expect.objectContaining({ event: '$pageview' })) posthog.opt_in_capturing() - expect(onCapture).toHaveBeenCalledTimes(2) - expect(onCapture).toHaveBeenCalledWith('$opt_in', expect.anything()) + expect(beforeSendMock).toHaveBeenCalledTimes(2) + expect(beforeSendMock).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' })) }) it('should send $pageview on opt in if is has not been captured', async () => { // Some other tests might call setTimeout after they've passed, so creating a new instance here. - const onCapture = jest.fn() - const posthog = createPostHog({ _onCapture: onCapture }) + const beforeSendMock = jest.fn().mockImplementation((e) => e) + const posthog = createPostHog({ before_send: beforeSendMock }) posthog.opt_in_capturing() - expect(onCapture).toHaveBeenCalledTimes(2) - expect(onCapture).toHaveBeenCalledWith('$opt_in', expect.anything()) - expect(onCapture).lastCalledWith('$pageview', expect.anything()) + expect(beforeSendMock).toHaveBeenCalledTimes(2) + expect(beforeSendMock).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' })) + expect(beforeSendMock).lastCalledWith(expect.objectContaining({ event: '$pageview' })) // Wait for the $pageview timeout to be called // eslint-disable-next-line compat/compat await new Promise((r) => setTimeout(r, 10)) - expect(onCapture).toHaveBeenCalledTimes(2) + expect(beforeSendMock).toHaveBeenCalledTimes(2) }) it('should not send $pageview on subsequent opt in', async () => { // Some other tests might call setTimeout after they've passed, so creating a new instance here. - const onCapture = jest.fn() - const posthog = createPostHog({ _onCapture: onCapture }) + const beforeSendMock = jest.fn().mockImplementation((e) => e) + const posthog = createPostHog({ before_send: beforeSendMock }) posthog.opt_in_capturing() - expect(onCapture).toHaveBeenCalledTimes(2) - expect(onCapture).toHaveBeenCalledWith('$opt_in', expect.anything()) - expect(onCapture).lastCalledWith('$pageview', expect.anything()) + expect(beforeSendMock).toHaveBeenCalledTimes(2) + expect(beforeSendMock).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' })) + expect(beforeSendMock).lastCalledWith(expect.objectContaining({ event: '$pageview' })) // Wait for the $pageview timeout to be called // eslint-disable-next-line compat/compat await new Promise((r) => setTimeout(r, 10)) posthog.opt_in_capturing() - expect(onCapture).toHaveBeenCalledTimes(3) - expect(onCapture).not.lastCalledWith('$pageview', expect.anything()) + expect(beforeSendMock).toHaveBeenCalledTimes(3) + expect(beforeSendMock).not.lastCalledWith(expect.objectContaining({ event: '$pageview' })) }) }) @@ -238,18 +238,18 @@ describe('consentManager', () => { }) it(`should capture an event recording the opt-in action`, () => { - const onCapture = jest.fn() - posthog.on('eventCaptured', onCapture) + const beforeSendMock = jest.fn() + posthog.on('eventCaptured', beforeSendMock) posthog.opt_in_capturing() - expect(onCapture).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' })) + expect(beforeSendMock).toHaveBeenCalledWith(expect.objectContaining({ event: '$opt_in' })) - onCapture.mockClear() + beforeSendMock.mockClear() const captureEventName = `єνєηт` const captureProperties = { '𝖕𝖗𝖔𝖕𝖊𝖗𝖙𝖞': `𝓿𝓪𝓵𝓾𝓮` } posthog.opt_in_capturing({ captureEventName, captureProperties }) - expect(onCapture).toHaveBeenCalledWith( + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ event: captureEventName, properties: expect.objectContaining(captureProperties), diff --git a/src/__tests__/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.test.ts b/src/__tests__/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.test.ts new file mode 100644 index 000000000..f1ec7fbae --- /dev/null +++ b/src/__tests__/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.test.ts @@ -0,0 +1,88 @@ +import { uuidv7 } from '../../uuidv7' +import { createPosthogInstance } from '../helpers/posthog-instance' +import { setAllPersonProfilePropertiesAsPersonPropertiesForFlags } from '../../customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags' +import { STORED_PERSON_PROPERTIES_KEY } from '../../constants' + +jest.mock('../../utils/globals', () => { + const orig = jest.requireActual('../../utils/globals') + const mockURLGetter = jest.fn() + const mockReferrerGetter = jest.fn() + return { + ...orig, + mockURLGetter, + mockReferrerGetter, + userAgent: 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0', + navigator: { + vendor: '', + }, + document: { + ...orig.document, + createElement: (...args: any[]) => orig.document.createElement(...args), + get referrer() { + return mockReferrerGetter() + }, + get URL() { + return mockURLGetter() + }, + }, + get location() { + const url = mockURLGetter() + return { + href: url, + toString: () => url, + } + }, + } +}) + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { mockURLGetter, mockReferrerGetter } = require('../../utils/globals') + +describe('setAllPersonPropertiesForFlags', () => { + beforeEach(() => { + mockReferrerGetter.mockReturnValue('https://referrer.com') + mockURLGetter.mockReturnValue('https://example.com?utm_source=foo') + }) + + it('should called setPersonPropertiesForFlags with all saved properties that are used for person properties', async () => { + // arrange + const token = uuidv7() + const posthog = await createPosthogInstance(token) + + // act + setAllPersonProfilePropertiesAsPersonPropertiesForFlags(posthog) + + // assert + expect(posthog.persistence?.props[STORED_PERSON_PROPERTIES_KEY]).toMatchInlineSnapshot(` + Object { + "$browser": "Mobile Safari", + "$browser_version": null, + "$current_url": "https://example.com?utm_source=foo", + "$device_type": "Mobile", + "$os": "Android", + "$os_version": "4.4.0", + "$referrer": "https://referrer.com", + "$referring_domain": "referrer.com", + "dclid": null, + "fbclid": null, + "gad_source": null, + "gbraid": null, + "gclid": null, + "gclsrc": null, + "igshid": null, + "li_fat_id": null, + "mc_cid": null, + "msclkid": null, + "rdt_cid": null, + "ttclid": null, + "twclid": null, + "utm_campaign": null, + "utm_content": null, + "utm_medium": null, + "utm_source": "foo", + "utm_term": null, + "wbraid": null, + } + `) + }) +}) diff --git a/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts b/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts index 492bae78b..ad934e2b1 100644 --- a/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts +++ b/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts @@ -35,7 +35,7 @@ describe('Exception Observer', () => { let exceptionObserver: ExceptionObserver let posthog: PostHog let sendRequestSpy: jest.SpyInstance - const mockCapture = jest.fn() + const beforeSendMock = jest.fn().mockImplementation((e) => e) const loadScriptMock = jest.fn() const addErrorWrappingFlagToWindow = () => { @@ -51,7 +51,7 @@ describe('Exception Observer', () => { callback() }) - posthog = await createPosthogInstance(uuidv7(), { _onCapture: mockCapture }) + posthog = await createPosthogInstance(uuidv7(), { before_send: beforeSendMock }) assignableWindow.__PosthogExtensions__ = { loadExternalDependency: loadScriptMock, } @@ -91,11 +91,11 @@ describe('Exception Observer', () => { const error = new Error('test error') window!.onerror?.call(window, 'message', 'source', 0, 0, error) - const captureCalls = mockCapture.mock.calls + const captureCalls = beforeSendMock.mock.calls expect(captureCalls.length).toBe(1) const singleCall = captureCalls[0] - expect(singleCall[0]).toBe('$exception') - expect(singleCall[1]).toMatchObject({ + expect(singleCall[0]).toMatchObject({ + event: '$exception', properties: { $exception_personURL: expect.any(String), $exception_list: [ @@ -120,11 +120,11 @@ describe('Exception Observer', () => { }) window!.onunhandledrejection?.call(window!, promiseRejectionEvent) - const captureCalls = mockCapture.mock.calls + const captureCalls = beforeSendMock.mock.calls expect(captureCalls.length).toBe(1) const singleCall = captureCalls[0] - expect(singleCall[0]).toBe('$exception') - expect(singleCall[1]).toMatchObject({ + expect(singleCall[0]).toMatchObject({ + event: '$exception', properties: { $exception_personURL: expect.any(String), $exception_list: [ diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index 3b4cadfbf..53199fbf8 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -48,6 +48,7 @@ import { import Mock = jest.Mock import { ConsentManager } from '../../../consent' import { waitFor } from '@testing-library/preact' +import { SimpleEventEmitter } from '../../../utils/simple-event-emitter' // Type and source defined here designate a non-user-generated recording event @@ -185,6 +186,7 @@ describe('SessionRecording', () => { let onFeatureFlagsCallback: ((flags: string[], variants: Record) => void) | null let removeCaptureHookMock: Mock let addCaptureHookMock: Mock + let simpleEventEmitter: SimpleEventEmitter const addRRwebToWindow = () => { assignableWindow.__PosthogExtensions__.rrweb = { @@ -239,6 +241,8 @@ describe('SessionRecording', () => { removeCaptureHookMock = jest.fn() addCaptureHookMock = jest.fn().mockImplementation(() => removeCaptureHookMock) + simpleEventEmitter = new SimpleEventEmitter() + // TODO we really need to make this a real posthog instance :cry: posthog = { get_property: (property_key: string): Property | undefined => { return postHogPersistence?.['props'][property_key] @@ -261,6 +265,10 @@ describe('SessionRecording', () => { }, } as unknown as ConsentManager, register_for_session() {}, + _internalEventEmitter: simpleEventEmitter, + on: (event, cb) => { + return simpleEventEmitter.on(event, cb) + }, } as Partial as PostHog loadScriptMock.mockImplementation((_ph, _path, callback) => { @@ -1883,6 +1891,7 @@ describe('SessionRecording', () => { loadScriptMock.mockImplementation((_ph, _path, callback) => { callback() }) + sessionRecording = new SessionRecording(posthog) sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) sessionRecording.startIfEnabledOrStop() @@ -2250,4 +2259,67 @@ describe('SessionRecording', () => { ]) }) }) + + describe('Event triggering', () => { + beforeEach(() => { + sessionRecording.startIfEnabledOrStop() + }) + + it('flushes buffer and starts when sees event', async () => { + sessionRecording.afterDecideResponse( + makeDecideResponse({ + sessionRecording: { + endpoint: '/s/', + eventTriggers: ['$exception'], + }, + }) + ) + + expect(sessionRecording['status']).toBe('buffering') + + // Emit some events before hitting blocked URL + _emit(createIncrementalSnapshot({ data: { source: 1 } })) + _emit(createIncrementalSnapshot({ data: { source: 2 } })) + + expect(sessionRecording['buffer'].data).toHaveLength(2) + + simpleEventEmitter.emit('eventCaptured', { event: 'not-$exception' }) + + expect(sessionRecording['status']).toBe('buffering') + + simpleEventEmitter.emit('eventCaptured', { event: '$exception' }) + + expect(sessionRecording['status']).toBe('active') + expect(sessionRecording['buffer'].data).toHaveLength(0) + }) + + it('starts if sees an event but still waiting for a URL', async () => { + sessionRecording.afterDecideResponse( + makeDecideResponse({ + sessionRecording: { + endpoint: '/s/', + eventTriggers: ['$exception'], + urlTriggers: [{ url: 'start-on-me', matching: 'regex' }], + }, + }) + ) + + expect(sessionRecording['status']).toBe('buffering') + + // Emit some events before hitting blocked URL + _emit(createIncrementalSnapshot({ data: { source: 1 } })) + _emit(createIncrementalSnapshot({ data: { source: 2 } })) + + expect(sessionRecording['buffer'].data).toHaveLength(2) + + simpleEventEmitter.emit('eventCaptured', { event: 'not-$exception' }) + + expect(sessionRecording['status']).toBe('buffering') + + simpleEventEmitter.emit('eventCaptured', { event: '$exception' }) + + // even though still waiting for URL to trigger + expect(sessionRecording['status']).toBe('active') + }) + }) }) diff --git a/src/__tests__/extensions/web-vitals.test.ts b/src/__tests__/extensions/web-vitals.test.ts index e83ae0494..5cd284a13 100644 --- a/src/__tests__/extensions/web-vitals.test.ts +++ b/src/__tests__/extensions/web-vitals.test.ts @@ -10,7 +10,7 @@ jest.useFakeTimers() describe('web vitals', () => { let posthog: PostHog - let onCapture = jest.fn() + let beforeSendMock = jest.fn().mockImplementation((e) => e) let onLCPCallback: ((metric: Record) => void) | undefined = undefined let onCLSCallback: ((metric: Record) => void) | undefined = undefined let onFCPCallback: ((metric: Record) => void) | undefined = undefined @@ -81,9 +81,9 @@ describe('web vitals', () => { expectedProperties: Record ) => { beforeEach(async () => { - onCapture.mockClear() + beforeSendMock.mockClear() posthog = await createPosthogInstance(uuidv7(), { - _onCapture: onCapture, + before_send: beforeSendMock, capture_performance: { web_vitals: true, web_vitals_allowed_metrics: clientConfig }, // sometimes pageviews sneak in and make asserting on mock capture tricky capture_pageview: false, @@ -123,10 +123,9 @@ describe('web vitals', () => { it('should emit when all allowed metrics are captured', async () => { emitAllMetrics() - expect(onCapture).toBeCalledTimes(1) + expect(beforeSendMock).toBeCalledTimes(1) - expect(onCapture.mock.lastCall).toMatchObject([ - '$web_vitals', + expect(beforeSendMock.mock.lastCall).toMatchObject([ { event: '$web_vitals', properties: expectedProperties, @@ -137,14 +136,12 @@ describe('web vitals', () => { it('should emit after 5 seconds even when only 1 to 3 metrics captured', async () => { onCLSCallback?.({ name: 'CLS', value: 123.45, extra: 'property' }) - expect(onCapture).toBeCalledTimes(0) + expect(beforeSendMock).toBeCalledTimes(0) jest.advanceTimersByTime(DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1) // for some reason advancing the timer emits a $pageview event as well 🤷 - // expect(onCapture).toBeCalledTimes(2) - expect(onCapture.mock.lastCall).toMatchObject([ - '$web_vitals', + expect(beforeSendMock.mock.lastCall).toMatchObject([ { event: '$web_vitals', properties: { @@ -159,12 +156,11 @@ describe('web vitals', () => { ;(posthog.config.capture_performance as PerformanceCaptureConfig).web_vitals_delayed_flush_ms = 1000 onCLSCallback?.({ name: 'CLS', value: 123.45, extra: 'property' }) - expect(onCapture).toBeCalledTimes(0) + expect(beforeSendMock).toBeCalledTimes(0) jest.advanceTimersByTime(1000 + 1) - expect(onCapture.mock.lastCall).toMatchObject([ - '$web_vitals', + expect(beforeSendMock.mock.lastCall).toMatchObject([ { event: '$web_vitals', properties: { @@ -178,22 +174,22 @@ describe('web vitals', () => { it('should ignore a ridiculous value', async () => { onCLSCallback?.({ name: 'CLS', value: FIFTEEN_MINUTES_IN_MILLIS, extra: 'property' }) - expect(onCapture).toBeCalledTimes(0) + expect(beforeSendMock).toBeCalledTimes(0) jest.advanceTimersByTime(DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1) - expect(onCapture.mock.calls).toEqual([]) + expect(beforeSendMock.mock.calls).toEqual([]) }) it('can be configured not to ignore a ridiculous value', async () => { posthog.config.capture_performance = { __web_vitals_max_value: 0 } onCLSCallback?.({ name: 'CLS', value: FIFTEEN_MINUTES_IN_MILLIS, extra: 'property' }) - expect(onCapture).toBeCalledTimes(0) + expect(beforeSendMock).toBeCalledTimes(0) jest.advanceTimersByTime(DEFAULT_FLUSH_TO_CAPTURE_TIMEOUT_MILLISECONDS + 1) - expect(onCapture).toBeCalledTimes(1) + expect(beforeSendMock).toBeCalledTimes(1) }) } ) @@ -217,9 +213,9 @@ describe('web vitals', () => { }, } - onCapture = jest.fn() + beforeSendMock = jest.fn() posthog = await createPosthogInstance(uuidv7(), { - _onCapture: onCapture, + before_send: beforeSendMock, }) }) diff --git a/src/__tests__/heatmaps.test.ts b/src/__tests__/heatmaps.test.ts index 95a4c4fb5..0f1588d2c 100644 --- a/src/__tests__/heatmaps.test.ts +++ b/src/__tests__/heatmaps.test.ts @@ -12,7 +12,7 @@ jest.useFakeTimers() describe('heatmaps', () => { let posthog: PostHog - let onCapture = jest.fn() + let beforeSendMock = jest.fn().mockImplementation((e) => e) const createMockMouseEvent = (props: Partial = {}) => ({ @@ -23,10 +23,10 @@ describe('heatmaps', () => { } as unknown as MouseEvent) beforeEach(async () => { - onCapture = onCapture.mockClear() + beforeSendMock = beforeSendMock.mockClear() posthog = await createPosthogInstance(uuidv7(), { - _onCapture: onCapture, + before_send: beforeSendMock, sanitize_properties: (props) => { // what ever sanitization makes sense const sanitizeUrl = (url: string) => url.replace(/https?:\/\/[^/]+/g, 'http://replaced') @@ -61,9 +61,8 @@ describe('heatmaps', () => { jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds + 1) - expect(onCapture).toBeCalledTimes(1) - expect(onCapture.mock.lastCall[0]).toEqual('$$heatmap') - expect(onCapture.mock.lastCall[1]).toMatchObject({ + expect(beforeSendMock).toBeCalledTimes(1) + expect(beforeSendMock.mock.lastCall[0]).toMatchObject({ event: '$$heatmap', properties: { $heatmap_data: { @@ -85,7 +84,7 @@ describe('heatmaps', () => { jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds - 1) - expect(onCapture).toBeCalledTimes(0) + expect(beforeSendMock).toBeCalledTimes(0) expect(posthog.heatmaps!.getAndClearBuffer()).toBeDefined() }) @@ -96,9 +95,9 @@ describe('heatmaps', () => { jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds + 1) - expect(onCapture).toBeCalledTimes(1) - expect(onCapture.mock.lastCall[0]).toEqual('$$heatmap') - const heatmapData = onCapture.mock.lastCall[1].properties.$heatmap_data + expect(beforeSendMock).toBeCalledTimes(1) + expect(beforeSendMock.mock.lastCall[0].event).toEqual('$$heatmap') + const heatmapData = beforeSendMock.mock.lastCall[0].properties.$heatmap_data expect(heatmapData).toBeDefined() expect(heatmapData['http://replaced/']).toHaveLength(4) expect(heatmapData['http://replaced/'].map((x) => x.type)).toEqual(['click', 'click', 'rageclick', 'click']) @@ -110,16 +109,16 @@ describe('heatmaps', () => { jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds + 1) - expect(onCapture).toBeCalledTimes(1) - expect(onCapture.mock.lastCall[0]).toEqual('$$heatmap') - expect(onCapture.mock.lastCall[1].properties.$heatmap_data).toBeDefined() - expect(onCapture.mock.lastCall[1].properties.$heatmap_data['http://replaced/']).toHaveLength(2) + expect(beforeSendMock).toBeCalledTimes(1) + expect(beforeSendMock.mock.lastCall[0].event).toEqual('$$heatmap') + expect(beforeSendMock.mock.lastCall[0].properties.$heatmap_data).toBeDefined() + expect(beforeSendMock.mock.lastCall[0].properties.$heatmap_data['http://replaced/']).toHaveLength(2) expect(posthog.heatmaps!['buffer']).toEqual(undefined) jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds + 1) - expect(onCapture).toBeCalledTimes(1) + expect(beforeSendMock).toBeCalledTimes(1) }) it('should ignore clicks if they come from the toolbar', async () => { @@ -143,17 +142,17 @@ describe('heatmaps', () => { }) ) expect(posthog.heatmaps?.getAndClearBuffer()).not.toEqual(undefined) - expect(onCapture.mock.calls).toEqual([]) + expect(beforeSendMock.mock.calls).toEqual([]) }) it('should ignore an empty buffer', async () => { - expect(onCapture.mock.calls).toEqual([]) + expect(beforeSendMock.mock.calls).toEqual([]) expect(posthog.heatmaps?.['buffer']).toEqual(undefined) jest.advanceTimersByTime(posthog.heatmaps!.flushIntervalMilliseconds + 1) - expect(onCapture.mock.calls).toEqual([]) + expect(beforeSendMock.mock.calls).toEqual([]) }) describe('isEnabled()', () => { diff --git a/src/__tests__/identify.test.ts b/src/__tests__/identify.test.ts index d435dbaec..d9f37571d 100644 --- a/src/__tests__/identify.test.ts +++ b/src/__tests__/identify.test.ts @@ -42,8 +42,8 @@ describe('identify', () => { it('should send $is_identified = true with the identify event and following events', async () => { // arrange const token = uuidv7() - const onCapture = jest.fn() - const posthog = await createPosthogInstance(token, { _onCapture: onCapture }) + const beforeSendMock = jest.fn().mockImplementation((e) => e) + const posthog = await createPosthogInstance(token, { before_send: beforeSendMock }) const distinctId = '123' // act @@ -52,12 +52,12 @@ describe('identify', () => { posthog.capture('custom event after identify') // assert - const eventBeforeIdentify = onCapture.mock.calls[0] - expect(eventBeforeIdentify[1].properties.$is_identified).toEqual(false) - const identifyCall = onCapture.mock.calls[1] - expect(identifyCall[0]).toEqual('$identify') - expect(identifyCall[1].properties.$is_identified).toEqual(true) - const eventAfterIdentify = onCapture.mock.calls[2] - expect(eventAfterIdentify[1].properties.$is_identified).toEqual(true) + const eventBeforeIdentify = beforeSendMock.mock.calls[0] + expect(eventBeforeIdentify[0].properties.$is_identified).toEqual(false) + const identifyCall = beforeSendMock.mock.calls[1] + expect(identifyCall[0].event).toEqual('$identify') + expect(identifyCall[0].properties.$is_identified).toEqual(true) + const eventAfterIdentify = beforeSendMock.mock.calls[2] + expect(eventAfterIdentify[0].properties.$is_identified).toEqual(true) }) }) diff --git a/src/__tests__/personProcessing.test.ts b/src/__tests__/personProcessing.test.ts index 7cbfcb809..5bb11e653 100644 --- a/src/__tests__/personProcessing.test.ts +++ b/src/__tests__/personProcessing.test.ts @@ -78,13 +78,13 @@ describe('person processing', () => { persistence_name?: string ) => { token = token || uuidv7() - const onCapture = jest.fn() + const beforeSendMock = jest.fn().mockImplementation((e) => e) const posthog = await createPosthogInstance(token, { - _onCapture: onCapture, + before_send: beforeSendMock, person_profiles, persistence_name, }) - return { token, onCapture, posthog } + return { token, beforeSendMock, posthog } } describe('init', () => { @@ -142,7 +142,7 @@ describe('person processing', () => { describe('identify', () => { it('should fail if process_person is set to never', async () => { // arrange - const { posthog, onCapture } = await setup('never') + const { posthog, beforeSendMock } = await setup('never') // act posthog.identify(distinctId) @@ -152,12 +152,12 @@ describe('person processing', () => { expect(jest.mocked(logger).error).toHaveBeenCalledWith( 'posthog.identify was called, but process_person is set to "never". This call will be ignored.' ) - expect(onCapture).toBeCalledTimes(0) + expect(beforeSendMock).toBeCalledTimes(0) }) it('should switch events to $person_process=true if process_person is identified_only', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') // act posthog.capture('custom event before identify') @@ -165,18 +165,18 @@ describe('person processing', () => { posthog.capture('custom event after identify') // assert expect(jest.mocked(logger).error).toBeCalledTimes(0) - const eventBeforeIdentify = onCapture.mock.calls[0] - expect(eventBeforeIdentify[1].properties.$process_person_profile).toEqual(false) - const identifyCall = onCapture.mock.calls[1] - expect(identifyCall[0]).toEqual('$identify') - expect(identifyCall[1].properties.$process_person_profile).toEqual(true) - const eventAfterIdentify = onCapture.mock.calls[2] - expect(eventAfterIdentify[1].properties.$process_person_profile).toEqual(true) + const eventBeforeIdentify = beforeSendMock.mock.calls[0] + expect(eventBeforeIdentify[0].properties.$process_person_profile).toEqual(false) + const identifyCall = beforeSendMock.mock.calls[1] + expect(identifyCall[0].event).toEqual('$identify') + expect(identifyCall[0].properties.$process_person_profile).toEqual(true) + const eventAfterIdentify = beforeSendMock.mock.calls[2] + expect(eventAfterIdentify[0].properties.$process_person_profile).toEqual(true) }) it('should not change $person_process if process_person is always', async () => { // arrange - const { posthog, onCapture } = await setup('always') + const { posthog, beforeSendMock } = await setup('always') // act posthog.capture('custom event before identify') @@ -184,26 +184,26 @@ describe('person processing', () => { posthog.capture('custom event after identify') // assert expect(jest.mocked(logger).error).toBeCalledTimes(0) - const eventBeforeIdentify = onCapture.mock.calls[0] - expect(eventBeforeIdentify[1].properties.$process_person_profile).toEqual(true) - const identifyCall = onCapture.mock.calls[1] - expect(identifyCall[0]).toEqual('$identify') - expect(identifyCall[1].properties.$process_person_profile).toEqual(true) - const eventAfterIdentify = onCapture.mock.calls[2] - expect(eventAfterIdentify[1].properties.$process_person_profile).toEqual(true) + const eventBeforeIdentify = beforeSendMock.mock.calls[0] + expect(eventBeforeIdentify[0].properties.$process_person_profile).toEqual(true) + const identifyCall = beforeSendMock.mock.calls[1] + expect(identifyCall[0].event).toEqual('$identify') + expect(identifyCall[0].properties.$process_person_profile).toEqual(true) + const eventAfterIdentify = beforeSendMock.mock.calls[2] + expect(eventAfterIdentify[0].properties.$process_person_profile).toEqual(true) }) it('should include initial referrer info in identify event if identified_only', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') // act posthog.identify(distinctId) // assert - const identifyCall = onCapture.mock.calls[0] - expect(identifyCall[0]).toEqual('$identify') - expect(identifyCall[1].$set_once).toEqual({ + const identifyCall = beforeSendMock.mock.calls[0] + expect(identifyCall[0].event).toEqual('$identify') + expect(identifyCall[0].$set_once).toEqual({ ...INITIAL_CAMPAIGN_PARAMS_NULL, $initial_current_url: 'https://example.com?utm_source=foo', $initial_host: 'example.com', @@ -216,7 +216,7 @@ describe('person processing', () => { it('should preserve initial referrer info across a separate session', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') mockReferrerGetter.mockReturnValue('https://referrer1.com') mockURLGetter.mockReturnValue('https://example1.com/pathname1?utm_source=foo1') @@ -236,17 +236,17 @@ describe('person processing', () => { posthog.capture('event s2 after identify') // assert - const eventS1 = onCapture.mock.calls[0] - const eventS2Before = onCapture.mock.calls[1] - const eventS2Identify = onCapture.mock.calls[2] - const eventS2After = onCapture.mock.calls[3] + const eventS1 = beforeSendMock.mock.calls[0] + const eventS2Before = beforeSendMock.mock.calls[1] + const eventS2Identify = beforeSendMock.mock.calls[2] + const eventS2After = beforeSendMock.mock.calls[3] - expect(eventS1[1].$set_once).toEqual(undefined) + expect(eventS1[0].$set_once).toEqual(undefined) - expect(eventS2Before[1].$set_once).toEqual(undefined) + expect(eventS2Before[0].$set_once).toEqual(undefined) - expect(eventS2Identify[0]).toEqual('$identify') - expect(eventS2Identify[1].$set_once).toEqual({ + expect(eventS2Identify[0].event).toEqual('$identify') + expect(eventS2Identify[0].$set_once).toEqual({ ...INITIAL_CAMPAIGN_PARAMS_NULL, $initial_current_url: 'https://example1.com/pathname1?utm_source=foo1', $initial_host: 'example1.com', @@ -256,8 +256,8 @@ describe('person processing', () => { $initial_utm_source: 'foo1', }) - expect(eventS2After[0]).toEqual('event s2 after identify') - expect(eventS2After[1].$set_once).toEqual({ + expect(eventS2After[0].event).toEqual('event s2 after identify') + expect(eventS2After[0].$set_once).toEqual({ ...INITIAL_CAMPAIGN_PARAMS_NULL, $initial_current_url: 'https://example1.com/pathname1?utm_source=foo1', $initial_host: 'example1.com', @@ -270,15 +270,15 @@ describe('person processing', () => { it('should include initial referrer info in identify event if always', async () => { // arrange - const { posthog, onCapture } = await setup('always') + const { posthog, beforeSendMock } = await setup('always') // act posthog.identify(distinctId) // assert - const identifyCall = onCapture.mock.calls[0] - expect(identifyCall[0]).toEqual('$identify') - expect(identifyCall[1].$set_once).toEqual({ + const identifyCall = beforeSendMock.mock.calls[0] + expect(identifyCall[0].event).toEqual('$identify') + expect(identifyCall[0].$set_once).toEqual({ ...INITIAL_CAMPAIGN_PARAMS_NULL, $initial_current_url: 'https://example.com?utm_source=foo', $initial_host: 'example.com', @@ -291,16 +291,16 @@ describe('person processing', () => { it('should include initial search params', async () => { // arrange - const { posthog, onCapture } = await setup('always') + const { posthog, beforeSendMock } = await setup('always') mockReferrerGetter.mockReturnValue('https://www.google.com?q=bar') // act posthog.identify(distinctId) // assert - const identifyCall = onCapture.mock.calls[0] - expect(identifyCall[0]).toEqual('$identify') - expect(identifyCall[1].$set_once).toEqual({ + const identifyCall = beforeSendMock.mock.calls[0] + expect(identifyCall[0].event).toEqual('$identify') + expect(identifyCall[0].$set_once).toEqual({ ...INITIAL_CAMPAIGN_PARAMS_NULL, $initial_current_url: 'https://example.com?utm_source=foo', $initial_host: 'example.com', @@ -315,7 +315,7 @@ describe('person processing', () => { it('should be backwards compatible with deprecated INITIAL_REFERRER_INFO and INITIAL_CAMPAIGN_PARAMS way of saving initial person props', async () => { // arrange - const { posthog, onCapture } = await setup('always') + const { posthog, beforeSendMock } = await setup('always') posthog.persistence!.props[INITIAL_REFERRER_INFO] = { referrer: 'https://deprecated-referrer.com', referring_domain: 'deprecated-referrer.com', @@ -328,9 +328,9 @@ describe('person processing', () => { posthog.identify(distinctId) // assert - const identifyCall = onCapture.mock.calls[0] - expect(identifyCall[0]).toEqual('$identify') - expect(identifyCall[1].$set_once).toEqual({ + const identifyCall = beforeSendMock.mock.calls[0] + expect(identifyCall[0].event).toEqual('$identify') + expect(identifyCall[0].$set_once).toEqual({ $initial_referrer: 'https://deprecated-referrer.com', $initial_referring_domain: 'deprecated-referrer.com', $initial_utm_source: 'deprecated-source', @@ -341,7 +341,7 @@ describe('person processing', () => { describe('capture', () => { it('should include initial referrer info iff the event has person processing when in identified_only mode', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') // act posthog.capture('custom event before identify') @@ -349,10 +349,10 @@ describe('person processing', () => { posthog.capture('custom event after identify') // assert - const eventBeforeIdentify = onCapture.mock.calls[0] - expect(eventBeforeIdentify[1].$set_once).toBeUndefined() - const eventAfterIdentify = onCapture.mock.calls[2] - expect(eventAfterIdentify[1].$set_once).toEqual({ + const eventBeforeIdentify = beforeSendMock.mock.calls[0] + expect(eventBeforeIdentify[0].$set_once).toBeUndefined() + const eventAfterIdentify = beforeSendMock.mock.calls[2] + expect(eventAfterIdentify[0].$set_once).toEqual({ ...INITIAL_CAMPAIGN_PARAMS_NULL, $initial_current_url: 'https://example.com?utm_source=foo', $initial_host: 'example.com', @@ -365,7 +365,7 @@ describe('person processing', () => { it('should add initial referrer to set_once when in always mode', async () => { // arrange - const { posthog, onCapture } = await setup('always') + const { posthog, beforeSendMock } = await setup('always') // act posthog.capture('custom event before identify') @@ -373,8 +373,8 @@ describe('person processing', () => { posthog.capture('custom event after identify') // assert - const eventBeforeIdentify = onCapture.mock.calls[0] - expect(eventBeforeIdentify[1].$set_once).toEqual({ + const eventBeforeIdentify = beforeSendMock.mock.calls[0] + expect(eventBeforeIdentify[0].$set_once).toEqual({ ...INITIAL_CAMPAIGN_PARAMS_NULL, $initial_current_url: 'https://example.com?utm_source=foo', $initial_host: 'example.com', @@ -383,8 +383,8 @@ describe('person processing', () => { $initial_referring_domain: 'referrer.com', $initial_utm_source: 'foo', }) - const eventAfterIdentify = onCapture.mock.calls[2] - expect(eventAfterIdentify[1].$set_once).toEqual({ + const eventAfterIdentify = beforeSendMock.mock.calls[2] + expect(eventAfterIdentify[0].$set_once).toEqual({ ...INITIAL_CAMPAIGN_PARAMS_NULL, $initial_current_url: 'https://example.com?utm_source=foo', $initial_host: 'example.com', @@ -399,7 +399,7 @@ describe('person processing', () => { describe('group', () => { it('should start person processing for identified_only users', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') // act posthog.capture('custom event before group') @@ -407,18 +407,18 @@ describe('person processing', () => { posthog.capture('custom event after group') // assert - const eventBeforeGroup = onCapture.mock.calls[0] - expect(eventBeforeGroup[1].properties.$process_person_profile).toEqual(false) - const groupIdentify = onCapture.mock.calls[1] - expect(groupIdentify[0]).toEqual('$groupidentify') - expect(groupIdentify[1].properties.$process_person_profile).toEqual(true) - const eventAfterGroup = onCapture.mock.calls[2] - expect(eventAfterGroup[1].properties.$process_person_profile).toEqual(true) + const eventBeforeGroup = beforeSendMock.mock.calls[0] + expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false) + const groupIdentify = beforeSendMock.mock.calls[1] + expect(groupIdentify[0].event).toEqual('$groupidentify') + expect(groupIdentify[0].properties.$process_person_profile).toEqual(true) + const eventAfterGroup = beforeSendMock.mock.calls[2] + expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(true) }) it('should not send the $groupidentify event if person_processing is set to never', async () => { // arrange - const { posthog, onCapture } = await setup('never') + const { posthog, beforeSendMock } = await setup('never') // act posthog.capture('custom event before group') @@ -431,24 +431,24 @@ describe('person processing', () => { 'posthog.group was called, but process_person is set to "never". This call will be ignored.' ) - expect(onCapture).toBeCalledTimes(2) - const eventBeforeGroup = onCapture.mock.calls[0] - expect(eventBeforeGroup[1].properties.$process_person_profile).toEqual(false) - const eventAfterGroup = onCapture.mock.calls[1] - expect(eventAfterGroup[1].properties.$process_person_profile).toEqual(false) + expect(beforeSendMock).toBeCalledTimes(2) + const eventBeforeGroup = beforeSendMock.mock.calls[0] + expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false) + const eventAfterGroup = beforeSendMock.mock.calls[1] + expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(false) }) }) describe('setPersonProperties', () => { it("should not send a $set event if process_person is set to 'never'", async () => { // arrange - const { posthog, onCapture } = await setup('never') + const { posthog, beforeSendMock } = await setup('never') // act posthog.setPersonProperties({ prop: 'value' }) // assert - expect(onCapture).toBeCalledTimes(0) + expect(beforeSendMock).toBeCalledTimes(0) expect(jest.mocked(logger).error).toBeCalledTimes(1) expect(jest.mocked(logger).error).toHaveBeenCalledWith( 'posthog.setPersonProperties was called, but process_person is set to "never". This call will be ignored.' @@ -457,19 +457,19 @@ describe('person processing', () => { it("should send a $set event if process_person is set to 'always'", async () => { // arrange - const { posthog, onCapture } = await setup('always') + const { posthog, beforeSendMock } = await setup('always') // act posthog.setPersonProperties({ prop: 'value' }) // assert - expect(onCapture).toBeCalledTimes(1) - expect(onCapture.mock.calls[0][0]).toEqual('$set') + expect(beforeSendMock).toBeCalledTimes(1) + expect(beforeSendMock.mock.calls[0][0].event).toEqual('$set') }) it('should start person processing for identified_only users', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') // act posthog.capture('custom event before setPersonProperties') @@ -477,20 +477,20 @@ describe('person processing', () => { posthog.capture('custom event after setPersonProperties') // assert - const eventBeforeGroup = onCapture.mock.calls[0] - expect(eventBeforeGroup[1].properties.$process_person_profile).toEqual(false) - const set = onCapture.mock.calls[1] - expect(set[0]).toEqual('$set') - expect(set[1].properties.$process_person_profile).toEqual(true) - const eventAfterGroup = onCapture.mock.calls[2] - expect(eventAfterGroup[1].properties.$process_person_profile).toEqual(true) + const eventBeforeGroup = beforeSendMock.mock.calls[0] + expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false) + const set = beforeSendMock.mock.calls[1] + expect(set[0].event).toEqual('$set') + expect(set[0].properties.$process_person_profile).toEqual(true) + const eventAfterGroup = beforeSendMock.mock.calls[2] + expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(true) }) }) describe('alias', () => { it('should start person processing for identified_only users', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') // act posthog.capture('custom event before alias') @@ -498,24 +498,24 @@ describe('person processing', () => { posthog.capture('custom event after alias') // assert - const eventBeforeGroup = onCapture.mock.calls[0] - expect(eventBeforeGroup[1].properties.$process_person_profile).toEqual(false) - const alias = onCapture.mock.calls[1] - expect(alias[0]).toEqual('$create_alias') - expect(alias[1].properties.$process_person_profile).toEqual(true) - const eventAfterGroup = onCapture.mock.calls[2] - expect(eventAfterGroup[1].properties.$process_person_profile).toEqual(true) + const eventBeforeGroup = beforeSendMock.mock.calls[0] + expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false) + const alias = beforeSendMock.mock.calls[1] + expect(alias[0].event).toEqual('$create_alias') + expect(alias[0].properties.$process_person_profile).toEqual(true) + const eventAfterGroup = beforeSendMock.mock.calls[2] + expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(true) }) it('should not send a $create_alias event if person processing is set to "never"', async () => { // arrange - const { posthog, onCapture } = await setup('never') + const { posthog, beforeSendMock } = await setup('never') // act posthog.alias('alias') // assert - expect(onCapture).toBeCalledTimes(0) + expect(beforeSendMock).toBeCalledTimes(0) expect(jest.mocked(logger).error).toBeCalledTimes(1) expect(jest.mocked(logger).error).toHaveBeenCalledWith( 'posthog.alias was called, but process_person is set to "never". This call will be ignored.' @@ -526,7 +526,7 @@ describe('person processing', () => { describe('createPersonProfile', () => { it('should start person processing for identified_only users', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') // act posthog.capture('custom event before createPersonProfile') @@ -534,19 +534,19 @@ describe('person processing', () => { posthog.capture('custom event after createPersonProfile') // assert - expect(onCapture.mock.calls.length).toEqual(3) - const eventBeforeGroup = onCapture.mock.calls[0] - expect(eventBeforeGroup[1].properties.$process_person_profile).toEqual(false) - const set = onCapture.mock.calls[1] - expect(set[0]).toEqual('$set') - expect(set[1].properties.$process_person_profile).toEqual(true) - const eventAfterGroup = onCapture.mock.calls[2] - expect(eventAfterGroup[1].properties.$process_person_profile).toEqual(true) + expect(beforeSendMock.mock.calls.length).toEqual(3) + const eventBeforeGroup = beforeSendMock.mock.calls[0] + expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false) + const set = beforeSendMock.mock.calls[1] + expect(set[0].event).toEqual('$set') + expect(set[0].properties.$process_person_profile).toEqual(true) + const eventAfterGroup = beforeSendMock.mock.calls[2] + expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(true) }) it('should do nothing if already has person profiles', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') // act posthog.capture('custom event before createPersonProfile') @@ -555,18 +555,18 @@ describe('person processing', () => { posthog.createPersonProfile() // assert - expect(onCapture.mock.calls.length).toEqual(3) + expect(beforeSendMock.mock.calls.length).toEqual(3) }) it("should not send an event if process_person is to set to 'always'", async () => { // arrange - const { posthog, onCapture } = await setup('always') + const { posthog, beforeSendMock } = await setup('always') // act posthog.createPersonProfile() // assert - expect(onCapture).toBeCalledTimes(0) + expect(beforeSendMock).toBeCalledTimes(0) expect(jest.mocked(logger).error).toBeCalledTimes(0) }) }) @@ -574,7 +574,7 @@ describe('person processing', () => { describe('reset', () => { it('should revert a back to anonymous state in identified_only', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') posthog.identify(distinctId) posthog.capture('custom event before reset') @@ -584,8 +584,8 @@ describe('person processing', () => { // assert expect(posthog._isIdentified()).toBe(false) - expect(onCapture.mock.calls.length).toEqual(3) - expect(onCapture.mock.calls[2][1].properties.$process_person_profile).toEqual(false) + expect(beforeSendMock.mock.calls.length).toEqual(3) + expect(beforeSendMock.mock.calls[2][0].properties.$process_person_profile).toEqual(false) }) }) @@ -593,9 +593,13 @@ describe('person processing', () => { it('should remember that a user set the mode to always on a previous visit', async () => { // arrange const persistenceName = uuidv7() - const { posthog: posthog1, onCapture: onCapture1 } = await setup('always', undefined, persistenceName) + const { posthog: posthog1, beforeSendMock: beforeSendMock1 } = await setup( + 'always', + undefined, + persistenceName + ) posthog1.capture('custom event 1') - const { posthog: posthog2, onCapture: onCapture2 } = await setup( + const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = await setup( 'identified_only', undefined, persistenceName @@ -605,38 +609,42 @@ describe('person processing', () => { posthog2.capture('custom event 2') // assert - expect(onCapture1.mock.calls.length).toEqual(1) - expect(onCapture2.mock.calls.length).toEqual(1) - expect(onCapture1.mock.calls[0][1].properties.$process_person_profile).toEqual(true) - expect(onCapture2.mock.calls[0][1].properties.$process_person_profile).toEqual(true) + expect(beforeSendMock1.mock.calls.length).toEqual(1) + expect(beforeSendMock2.mock.calls.length).toEqual(1) + expect(beforeSendMock1.mock.calls[0][0].properties.$process_person_profile).toEqual(true) + expect(beforeSendMock2.mock.calls[0][0].properties.$process_person_profile).toEqual(true) }) it('should work when always is set on a later visit', async () => { // arrange const persistenceName = uuidv7() - const { posthog: posthog1, onCapture: onCapture1 } = await setup( + const { posthog: posthog1, beforeSendMock: beforeSendMock1 } = await setup( 'identified_only', undefined, persistenceName ) posthog1.capture('custom event 1') - const { posthog: posthog2, onCapture: onCapture2 } = await setup('always', undefined, persistenceName) + const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = await setup( + 'always', + undefined, + persistenceName + ) // act posthog2.capture('custom event 2') // assert - expect(onCapture1.mock.calls.length).toEqual(1) - expect(onCapture2.mock.calls.length).toEqual(1) - expect(onCapture1.mock.calls[0][1].properties.$process_person_profile).toEqual(false) - expect(onCapture2.mock.calls[0][1].properties.$process_person_profile).toEqual(true) + expect(beforeSendMock1.mock.calls.length).toEqual(1) + expect(beforeSendMock2.mock.calls.length).toEqual(1) + expect(beforeSendMock1.mock.calls[0][0].properties.$process_person_profile).toEqual(false) + expect(beforeSendMock2.mock.calls[0][0].properties.$process_person_profile).toEqual(true) }) }) describe('decide', () => { it('should change the person mode from default when decide response is handled', async () => { // arrange - const { posthog, onCapture } = await setup(undefined) + const { posthog, beforeSendMock } = await setup(undefined) posthog.capture('startup page view') // act @@ -644,14 +652,14 @@ describe('person processing', () => { posthog.capture('custom event') // assert - expect(onCapture.mock.calls.length).toEqual(2) - expect(onCapture.mock.calls[0][1].properties.$process_person_profile).toEqual(false) - expect(onCapture.mock.calls[1][1].properties.$process_person_profile).toEqual(true) + expect(beforeSendMock.mock.calls.length).toEqual(2) + expect(beforeSendMock.mock.calls[0][0].properties.$process_person_profile).toEqual(false) + expect(beforeSendMock.mock.calls[1][0].properties.$process_person_profile).toEqual(true) }) it('should NOT change the person mode from user-defined when decide response is handled', async () => { // arrange - const { posthog, onCapture } = await setup('identified_only') + const { posthog, beforeSendMock } = await setup('identified_only') posthog.capture('startup page view') // act @@ -659,27 +667,35 @@ describe('person processing', () => { posthog.capture('custom event') // assert - expect(onCapture.mock.calls.length).toEqual(2) - expect(onCapture.mock.calls[0][1].properties.$process_person_profile).toEqual(false) - expect(onCapture.mock.calls[1][1].properties.$process_person_profile).toEqual(false) + expect(beforeSendMock.mock.calls.length).toEqual(2) + expect(beforeSendMock.mock.calls[0][0].properties.$process_person_profile).toEqual(false) + expect(beforeSendMock.mock.calls[1][0].properties.$process_person_profile).toEqual(false) }) it('should persist when the default person mode is overridden by decide', async () => { // arrange const persistenceName = uuidv7() - const { posthog: posthog1, onCapture: onCapture1 } = await setup(undefined, undefined, persistenceName) + const { posthog: posthog1, beforeSendMock: beforeSendMock1 } = await setup( + undefined, + undefined, + persistenceName + ) // act posthog1._afterDecideResponse({ defaultIdentifiedOnly: false } as DecideResponse) posthog1.capture('custom event 1') - const { posthog: posthog2, onCapture: onCapture2 } = await setup(undefined, undefined, persistenceName) + const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = await setup( + undefined, + undefined, + persistenceName + ) posthog2.capture('custom event 2') // assert - expect(onCapture1.mock.calls.length).toEqual(1) - expect(onCapture2.mock.calls.length).toEqual(1) - expect(onCapture1.mock.calls[0][1].properties.$process_person_profile).toEqual(true) - expect(onCapture2.mock.calls[0][1].properties.$process_person_profile).toEqual(true) + expect(beforeSendMock1.mock.calls.length).toEqual(1) + expect(beforeSendMock2.mock.calls.length).toEqual(1) + expect(beforeSendMock1.mock.calls[0][0].properties.$process_person_profile).toEqual(true) + expect(beforeSendMock2.mock.calls[0][0].properties.$process_person_profile).toEqual(true) }) }) }) diff --git a/src/__tests__/posthog-core.beforeSend.test.ts b/src/__tests__/posthog-core.beforeSend.test.ts new file mode 100644 index 000000000..35d0a4641 --- /dev/null +++ b/src/__tests__/posthog-core.beforeSend.test.ts @@ -0,0 +1,179 @@ +import { uuidv7 } from '../uuidv7' +import { defaultPostHog } from './helpers/posthog-instance' +import { logger } from '../utils/logger' +import { CaptureResult, knownUnsafeEditableEvent, PostHogConfig } from '../types' +import { PostHog } from '../posthog-core' + +jest.mock('../utils/logger') + +const rejectingEventFn = () => { + return null +} + +const editingEventFn = (captureResult: CaptureResult): CaptureResult => { + return { + ...captureResult, + properties: { + ...captureResult.properties, + edited: true, + }, + $set: { + ...captureResult.$set, + edited: true, + }, + } +} + +describe('posthog core - before send', () => { + const baseUTCDateTime = new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) + const eventName = '$event' + + const posthogWith = (configOverride: Pick, 'before_send'>): PostHog => { + const posthog = defaultPostHog().init('testtoken', configOverride, uuidv7()) + return Object.assign(posthog, { + _send_request: jest.fn(), + }) + } + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(baseUTCDateTime) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('can reject an event', () => { + const posthog = posthogWith({ + before_send: rejectingEventFn, + }) + ;(posthog._send_request as jest.Mock).mockClear() + + const capturedData = posthog.capture(eventName, {}, {}) + + expect(capturedData).toBeUndefined() + expect(posthog._send_request).not.toHaveBeenCalled() + expect(jest.mocked(logger).info).toHaveBeenCalledWith( + `Event '${eventName}' was rejected in beforeSend function` + ) + }) + + it('can edit an event', () => { + const posthog = posthogWith({ + before_send: editingEventFn, + }) + ;(posthog._send_request as jest.Mock).mockClear() + + const capturedData = posthog.capture(eventName, {}, {}) + + expect(capturedData).toHaveProperty(['properties', 'edited'], true) + expect(capturedData).toHaveProperty(['$set', 'edited'], true) + expect(posthog._send_request).toHaveBeenCalledWith({ + batchKey: undefined, + callback: expect.any(Function), + compression: 'best-available', + data: capturedData, + method: 'POST', + url: 'https://us.i.posthog.com/e/', + }) + }) + + it('can take an array of fns', () => { + const posthog = posthogWith({ + before_send: [ + (cr) => { + cr.properties = { ...cr.properties, edited_one: true } + return cr + }, + (cr) => { + if (cr.event === 'to reject') { + return null + } + return cr + }, + (cr) => { + cr.properties = { ...cr.properties, edited_two: true } + return cr + }, + ], + }) + ;(posthog._send_request as jest.Mock).mockClear() + + const capturedData = [posthog.capture(eventName, {}, {}), posthog.capture('to reject', {}, {})] + + expect(capturedData.filter((cd) => !!cd)).toHaveLength(1) + expect(capturedData[0]).toHaveProperty(['properties', 'edited_one'], true) + expect(capturedData[0]).toHaveProperty(['properties', 'edited_one'], true) + expect(posthog._send_request).toHaveBeenCalledWith({ + batchKey: undefined, + callback: expect.any(Function), + compression: 'best-available', + data: capturedData[0], + method: 'POST', + url: 'https://us.i.posthog.com/e/', + }) + }) + + it('can sanitize $set event', () => { + const posthog = posthogWith({ + before_send: (cr) => { + cr.$set = { value: 'edited' } + return cr + }, + }) + ;(posthog._send_request as jest.Mock).mockClear() + + const capturedData = posthog.capture('$set', {}, { $set: { value: 'provided' } }) + + expect(capturedData).toHaveProperty(['$set', 'value'], 'edited') + expect(posthog._send_request).toHaveBeenCalledWith({ + batchKey: undefined, + callback: expect.any(Function), + compression: 'best-available', + data: capturedData, + method: 'POST', + url: 'https://us.i.posthog.com/e/', + }) + }) + + it('warned when making arbitrary event invalid', () => { + const posthog = posthogWith({ + before_send: (cr) => { + cr.properties = undefined + return cr + }, + }) + ;(posthog._send_request as jest.Mock).mockClear() + + const capturedData = posthog.capture(eventName, { value: 'provided' }, {}) + + expect(capturedData).not.toHaveProperty(['properties', 'value'], 'provided') + expect(posthog._send_request).toHaveBeenCalledWith({ + batchKey: undefined, + callback: expect.any(Function), + compression: 'best-available', + data: capturedData, + method: 'POST', + url: 'https://us.i.posthog.com/e/', + }) + expect(jest.mocked(logger).warn).toHaveBeenCalledWith( + `Event '${eventName}' has no properties after beforeSend function, this is likely an error.` + ) + }) + + it('logs a warning when rejecting an unsafe to edit event', () => { + const posthog = posthogWith({ + before_send: rejectingEventFn, + }) + ;(posthog._send_request as jest.Mock).mockClear() + // chooses a random string from knownUnEditableEvent + const randomUnsafeEditableEvent = + knownUnsafeEditableEvent[Math.floor(Math.random() * knownUnsafeEditableEvent.length)] + + posthog.capture(randomUnsafeEditableEvent, {}, {}) + + expect(jest.mocked(logger).warn).toHaveBeenCalledWith( + `Event '${randomUnsafeEditableEvent}' was rejected in beforeSend function. This can cause unexpected behavior.` + ) + }) +}) diff --git a/src/__tests__/posthog-core.identify.test.ts b/src/__tests__/posthog-core.identify.test.ts index 0d696480f..96beace88 100644 --- a/src/__tests__/posthog-core.identify.test.ts +++ b/src/__tests__/posthog-core.identify.test.ts @@ -7,15 +7,15 @@ jest.mock('../decide') describe('identify()', () => { let instance: PostHog - let captureMock: jest.Mock + let beforeSendMock: jest.Mock beforeEach(() => { - captureMock = jest.fn() + beforeSendMock = jest.fn().mockImplementation((e) => e) const posthog = defaultPostHog().init( uuidv7(), { api_host: 'https://test.com', - _onCapture: captureMock, + before_send: beforeSendMock, }, uuidv7() ) @@ -46,9 +46,9 @@ describe('identify()', () => { instance.identify('calls capture when identity changes') - expect(captureMock).toHaveBeenCalledWith( - '$identify', + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ + event: '$identify', properties: expect.objectContaining({ distinct_id: 'calls capture when identity changes', $anon_distinct_id: 'oldIdentity', @@ -72,9 +72,9 @@ describe('identify()', () => { instance.identify('a-new-id') - expect(captureMock).toHaveBeenCalledWith( - '$identify', + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ + event: '$identify', properties: expect.objectContaining({ distinct_id: 'a-new-id', $anon_distinct_id: 'oldIdentity', @@ -91,9 +91,9 @@ describe('identify()', () => { instance.identify('a-new-id') - expect(captureMock).toHaveBeenCalledWith( - '$identify', + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ + event: '$identify', properties: expect.objectContaining({ distinct_id: 'a-new-id', $anon_distinct_id: 'oldIdentity', @@ -115,7 +115,7 @@ describe('identify()', () => { instance.identify('a-new-id') - expect(captureMock).not.toHaveBeenCalled() + expect(beforeSendMock).not.toHaveBeenCalled() expect(instance.featureFlags.setAnonymousDistinctId).not.toHaveBeenCalled() }) @@ -130,7 +130,7 @@ describe('identify()', () => { instance.identify('a-new-id') - expect(captureMock).not.toHaveBeenCalled() + expect(beforeSendMock).not.toHaveBeenCalled() expect(instance.featureFlags.setAnonymousDistinctId).not.toHaveBeenCalled() }) @@ -141,9 +141,9 @@ describe('identify()', () => { instance.identify('a-new-id') - expect(captureMock).toHaveBeenCalledWith( - '$identify', + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ + event: '$identify', properties: expect.objectContaining({ distinct_id: 'a-new-id', $anon_distinct_id: 'oldIdentity', @@ -155,9 +155,9 @@ describe('identify()', () => { it('calls capture with user properties if passed', () => { instance.identify('a-new-id', { email: 'john@example.com' }, { howOftenAmISet: 'once!' }) - expect(captureMock).toHaveBeenCalledWith( - '$identify', + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ + event: '$identify', properties: expect.objectContaining({ distinct_id: 'a-new-id', $anon_distinct_id: 'oldIdentity', @@ -178,16 +178,16 @@ describe('identify()', () => { it('does not capture or set user properties', () => { instance.identify('a-new-id') - expect(captureMock).not.toHaveBeenCalled() + expect(beforeSendMock).not.toHaveBeenCalled() expect(instance.featureFlags.setAnonymousDistinctId).not.toHaveBeenCalled() }) it('calls $set when user properties passed with same ID', () => { instance.identify('a-new-id', { email: 'john@example.com' }, { howOftenAmISet: 'once!' }) - expect(captureMock).toHaveBeenCalledWith( - '$set', + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ + event: '$set', // get set at the top level and in properties // $set: { email: 'john@example.com' }, // $set_once: expect.objectContaining({ howOftenAmISet: 'once!' }), @@ -209,7 +209,7 @@ describe('identify()', () => { instance.identify(null as unknown as string) - expect(captureMock).not.toHaveBeenCalled() + expect(beforeSendMock).not.toHaveBeenCalled() expect(instance.register).not.toHaveBeenCalled() expect(console.error).toHaveBeenCalledWith( '[PostHog.js]', @@ -274,9 +274,9 @@ describe('identify()', () => { it('captures a $set event', () => { instance.setPersonProperties({ email: 'john@example.com' }, { name: 'john' }) - expect(captureMock).toHaveBeenCalledWith( - '$set', + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ + event: '$set', // get set at the top level and in properties // $set: { email: 'john@example.com' }, // $set_once: expect.objectContaining({ name: 'john' }), @@ -291,9 +291,9 @@ describe('identify()', () => { it('calls proxies prople.set to setPersonProperties', () => { instance.people.set({ email: 'john@example.com' }) - expect(captureMock).toHaveBeenCalledWith( - '$set', + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ + event: '$set', properties: expect.objectContaining({ $set: { email: 'john@example.com' }, $set_once: {}, @@ -306,9 +306,9 @@ describe('identify()', () => { it('calls proxies prople.set_once to setPersonProperties', () => { instance.people.set_once({ email: 'john@example.com' }) - expect(captureMock).toHaveBeenCalledWith( - '$set', + expect(beforeSendMock).toHaveBeenCalledWith( expect.objectContaining({ + event: '$set', properties: expect.objectContaining({ $set: {}, $set_once: { email: 'john@example.com' }, diff --git a/src/__tests__/posthog-core.test.ts b/src/__tests__/posthog-core.test.ts index af1ca4b01..61d638339 100644 --- a/src/__tests__/posthog-core.test.ts +++ b/src/__tests__/posthog-core.test.ts @@ -2,36 +2,30 @@ import { defaultPostHog } from './helpers/posthog-instance' import type { PostHogConfig } from '../types' import { uuidv7 } from '../uuidv7' -const mockReferrerGetter = jest.fn() -const mockURLGetter = jest.fn() -jest.mock('../utils/globals', () => { - const orig = jest.requireActual('../utils/globals') - return { - ...orig, - document: { - ...orig.document, - createElement: (...args: any[]) => orig.document.createElement(...args), - get referrer() { - return mockReferrerGetter?.() - }, - get URL() { - return mockURLGetter?.() - }, - }, - get location() { - const url = mockURLGetter?.() - return { - href: url, - toString: () => url, - } - }, - } -}) - describe('posthog core', () => { + const mockURL = jest.fn() + const mockReferrer = jest.fn() + + beforeAll(() => { + // Mock getters using Object.defineProperty + Object.defineProperty(document, 'URL', { + get: mockURL, + }) + Object.defineProperty(document, 'referrer', { + get: mockReferrer, + }) + Object.defineProperty(window, 'location', { + get: () => ({ + href: mockURL(), + toString: () => mockURL(), + }), + configurable: true, + }) + }) + beforeEach(() => { - mockReferrerGetter.mockReturnValue('https://referrer.com') - mockURLGetter.mockReturnValue('https://example.com') + mockReferrer.mockReturnValue('https://referrer.com') + mockURL.mockReturnValue('https://example.com') console.error = jest.fn() }) @@ -45,10 +39,10 @@ describe('posthog core', () => { event: 'prop', } const setup = (config: Partial = {}, token: string = uuidv7()) => { - const onCapture = jest.fn() - const posthog = defaultPostHog().init(token, { ...config, _onCapture: onCapture }, token)! + const beforeSendMock = jest.fn().mockImplementation((e) => e) + const posthog = defaultPostHog().init(token, { ...config, before_send: beforeSendMock }, token)! posthog.debug() - return { posthog, onCapture } + return { posthog, beforeSendMock } } it('respects property_denylist and property_blacklist', () => { @@ -71,11 +65,11 @@ describe('posthog core', () => { describe('rate limiting', () => { it('includes information about remaining rate limit', () => { - const { posthog, onCapture } = setup() + const { posthog, beforeSendMock } = setup() posthog.capture(eventName, eventProperties) - expect(onCapture.mock.calls[0][1]).toMatchObject({ + expect(beforeSendMock.mock.calls[0][0]).toMatchObject({ properties: { $lib_rate_limit_remaining_tokens: 99, }, @@ -87,18 +81,18 @@ describe('posthog core', () => { jest.setSystemTime(Date.now()) console.error = jest.fn() - const { posthog, onCapture } = setup() + const { posthog, beforeSendMock } = setup() for (let i = 0; i < 100; i++) { posthog.capture(eventName, eventProperties) } - expect(onCapture).toHaveBeenCalledTimes(100) - onCapture.mockClear() + expect(beforeSendMock).toHaveBeenCalledTimes(100) + beforeSendMock.mockClear() ;(console.error as any).mockClear() for (let i = 0; i < 50; i++) { posthog.capture(eventName, eventProperties) } - expect(onCapture).toHaveBeenCalledTimes(1) - expect(onCapture.mock.calls[0][0]).toBe('$$client_ingestion_warning') + expect(beforeSendMock).toHaveBeenCalledTimes(1) + expect(beforeSendMock.mock.calls[0][0].event).toBe('$$client_ingestion_warning') expect(console.error).toHaveBeenCalledTimes(50) expect(console.error).toHaveBeenCalledWith( '[PostHog.js]', @@ -111,8 +105,8 @@ describe('posthog core', () => { it("should send referrer info with the event's properties", () => { // arrange const token = uuidv7() - mockReferrerGetter.mockReturnValue('https://referrer.example.com/some/path') - const { posthog, onCapture } = setup({ + mockReferrer.mockReturnValue('https://referrer.example.com/some/path') + const { posthog, beforeSendMock } = setup({ token, persistence_name: token, person_profiles: 'always', @@ -122,7 +116,7 @@ describe('posthog core', () => { posthog.capture(eventName, eventProperties) // assert - const { $set_once, properties } = onCapture.mock.calls[0][1] + const { $set_once, properties } = beforeSendMock.mock.calls[0][0] expect($set_once['$initial_referrer']).toBe('https://referrer.example.com/some/path') expect($set_once['$initial_referring_domain']).toBe('referrer.example.com') expect(properties['$referrer']).toBe('https://referrer.example.com/some/path') @@ -132,15 +126,15 @@ describe('posthog core', () => { it('should not update the referrer within the same session', () => { // arrange const token = uuidv7() - mockReferrerGetter.mockReturnValue('https://referrer1.example.com/some/path') + mockReferrer.mockReturnValue('https://referrer1.example.com/some/path') const { posthog: posthog1 } = setup({ token, persistence_name: token, person_profiles: 'always', }) posthog1.capture(eventName, eventProperties) - mockReferrerGetter.mockReturnValue('https://referrer2.example.com/some/path') - const { posthog: posthog2, onCapture: onCapture2 } = setup({ + mockReferrer.mockReturnValue('https://referrer2.example.com/some/path') + const { posthog: posthog2, beforeSendMock } = setup({ token, persistence_name: token, }) @@ -153,7 +147,7 @@ describe('posthog core', () => { 'https://referrer1.example.com/some/path' ) expect(posthog2.sessionPersistence!.props.$referrer).toEqual('https://referrer1.example.com/some/path') - const { $set_once, properties } = onCapture2.mock.calls[0][1] + const { $set_once, properties } = beforeSendMock.mock.calls[0][0] expect($set_once['$initial_referrer']).toBe('https://referrer1.example.com/some/path') expect($set_once['$initial_referring_domain']).toBe('referrer1.example.com') expect(properties['$referrer']).toBe('https://referrer1.example.com/some/path') @@ -163,15 +157,15 @@ describe('posthog core', () => { it('should use the new referrer in a new session', () => { // arrange const token = uuidv7() - mockReferrerGetter.mockReturnValue('https://referrer1.example.com/some/path') + mockReferrer.mockReturnValue('https://referrer1.example.com/some/path') const { posthog: posthog1 } = setup({ token, persistence_name: token, person_profiles: 'always', }) posthog1.capture(eventName, eventProperties) - mockReferrerGetter.mockReturnValue('https://referrer2.example.com/some/path') - const { posthog: posthog2, onCapture: onCapture2 } = setup({ + mockReferrer.mockReturnValue('https://referrer2.example.com/some/path') + const { posthog: posthog2, beforeSendMock: beforeSendMock2 } = setup({ token, persistence_name: token, }) @@ -184,7 +178,7 @@ describe('posthog core', () => { expect(posthog2.persistence!.props.$initial_person_info.r).toEqual( 'https://referrer1.example.com/some/path' ) - const { $set_once, properties } = onCapture2.mock.calls[0][1] + const { $set_once, properties } = beforeSendMock2.mock.calls[0][0] expect($set_once['$initial_referrer']).toBe('https://referrer1.example.com/some/path') expect($set_once['$initial_referring_domain']).toBe('referrer1.example.com') expect(properties['$referrer']).toBe('https://referrer2.example.com/some/path') @@ -194,8 +188,8 @@ describe('posthog core', () => { it('should use $direct when there is no referrer', () => { // arrange const token = uuidv7() - mockReferrerGetter.mockReturnValue('') - const { posthog, onCapture } = setup({ + mockReferrer.mockReturnValue('') + const { posthog, beforeSendMock } = setup({ token, persistence_name: token, person_profiles: 'always', @@ -205,7 +199,7 @@ describe('posthog core', () => { posthog.capture(eventName, eventProperties) // assert - const { $set_once, properties } = onCapture.mock.calls[0][1] + const { $set_once, properties } = beforeSendMock.mock.calls[0][0] expect($set_once['$initial_referrer']).toBe('$direct') expect($set_once['$initial_referring_domain']).toBe('$direct') expect(properties['$referrer']).toBe('$direct') @@ -217,8 +211,8 @@ describe('posthog core', () => { it('should not send campaign params as null if there are no non-null ones', () => { // arrange const token = uuidv7() - mockURLGetter.mockReturnValue('https://www.example.com/some/path') - const { posthog, onCapture } = setup({ + mockURL.mockReturnValue('https://www.example.com/some/path') + const { posthog, beforeSendMock } = setup({ token, persistence_name: token, }) @@ -227,15 +221,15 @@ describe('posthog core', () => { posthog.capture('$pageview') //assert - expect(onCapture.mock.calls[0][1].properties).not.toHaveProperty('utm_source') - expect(onCapture.mock.calls[0][1].properties).not.toHaveProperty('utm_medium') + expect(beforeSendMock.mock.calls[0][0].properties).not.toHaveProperty('utm_source') + expect(beforeSendMock.mock.calls[0][0].properties).not.toHaveProperty('utm_medium') }) it('should send present campaign params, and nulls for others', () => { // arrange const token = uuidv7() - mockURLGetter.mockReturnValue('https://www.example.com/some/path?utm_source=source') - const { posthog, onCapture } = setup({ + mockURL.mockReturnValue('https://www.example.com/some/path?utm_source=source') + const { posthog, beforeSendMock } = setup({ token, persistence_name: token, }) @@ -244,8 +238,8 @@ describe('posthog core', () => { posthog.capture('$pageview') //assert - expect(onCapture.mock.calls[0][1].properties.utm_source).toBe('source') - expect(onCapture.mock.calls[0][1].properties.utm_medium).toBe(null) + expect(beforeSendMock.mock.calls[0][0].properties.utm_source).toBe('source') + expect(beforeSendMock.mock.calls[0][0].properties.utm_medium).toBe(null) }) }) }) diff --git a/src/__tests__/posthog-core.ts b/src/__tests__/posthog-core.ts index a64fd0d46..7f37eec5d 100644 --- a/src/__tests__/posthog-core.ts +++ b/src/__tests__/posthog-core.ts @@ -96,7 +96,6 @@ describe('posthog core', () => { { property_denylist: [], property_blacklist: [], - _onCapture: jest.fn(), store_google: true, save_referrer: true, }, @@ -168,13 +167,12 @@ describe('posthog core', () => { 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36', } - const hook = jest.fn() + const hook = jest.fn().mockImplementation((event) => event) const posthog = posthogWith( { opt_out_useragent_filter: true, property_denylist: [], property_blacklist: [], - _onCapture: jest.fn(), }, defaultOverrides ) @@ -198,7 +196,6 @@ describe('posthog core', () => { properties_string_max_length: 1000, property_denylist: [], property_blacklist: [], - _onCapture: jest.fn(), }, defaultOverrides ) @@ -220,7 +217,6 @@ describe('posthog core', () => { properties_string_max_length: undefined, property_denylist: [], property_blacklist: [], - _onCapture: jest.fn(), }, defaultOverrides ) @@ -269,7 +265,6 @@ describe('posthog core', () => { { property_denylist: [], property_blacklist: [], - _onCapture: jest.fn(), }, defaultOverrides ) diff --git a/src/__tests__/posthog-persistence.test.ts b/src/__tests__/posthog-persistence.test.ts index 3860ad30d..086246e24 100644 --- a/src/__tests__/posthog-persistence.test.ts +++ b/src/__tests__/posthog-persistence.test.ts @@ -3,6 +3,9 @@ import { PostHogPersistence } from '../posthog-persistence' import { SESSION_ID, USER_STATE } from '../constants' import { PostHogConfig } from '../types' import Mock = jest.Mock +import { PostHog } from '../posthog-core' +import { window } from '../utils/globals' +import { uuidv7 } from '../uuidv7' let referrer = '' // No referrer by default Object.defineProperty(document, 'referrer', { get: () => referrer }) @@ -203,4 +206,45 @@ describe('persistence', () => { expect(lib.properties()).toEqual(expectedProps()) }) }) + + describe('posthog', () => { + it('should not store anything in localstorage, or cookies when in sessionStorage mode', () => { + const token = uuidv7() + const persistenceKey = `ph_${token}_posthog` + const posthog = new PostHog().init(token, { + persistence: 'sessionStorage', + }) + posthog.register({ distinct_id: 'test', test_prop: 'test_val' }) + posthog.capture('test_event') + expect(window.localStorage.getItem(persistenceKey)).toEqual(null) + expect(document.cookie).toEqual('') + expect(window.sessionStorage.getItem(persistenceKey)).toBeTruthy() + }) + + it('should not store anything in localstorage, sessionstorage, or cookies when in memory mode', () => { + const token = uuidv7() + const persistenceKey = `ph_${token}_posthog` + const posthog = new PostHog().init(token, { + persistence: 'memory', + }) + posthog.register({ distinct_id: 'test', test_prop: 'test_val' }) + posthog.capture('test_event') + expect(window.localStorage.getItem(persistenceKey)).toEqual(null) + expect(window.sessionStorage.getItem(persistenceKey)).toEqual(null) + expect(document.cookie).toEqual('') + }) + + it('should not store anything in cookies when in localstorage mode', () => { + const token = uuidv7() + const persistenceKey = `ph_${token}_posthog` + const posthog = new PostHog().init(token, { + persistence: 'localStorage', + }) + posthog.register({ distinct_id: 'test', test_prop: 'test_val' }) + posthog.capture('test_event') + expect(window.localStorage.getItem(persistenceKey)).toBeTruthy() + expect(window.sessionStorage.getItem(persistenceKey)).toBeTruthy() + expect(document.cookie).toEqual('') + }) + }) }) diff --git a/src/__tests__/utils/before-send-utils.test.ts b/src/__tests__/utils/before-send-utils.test.ts new file mode 100644 index 000000000..fe4139ad9 --- /dev/null +++ b/src/__tests__/utils/before-send-utils.test.ts @@ -0,0 +1,108 @@ +import { CaptureResult } from '../../types' +import { isNull } from '../../utils/type-utils' +import { sampleByDistinctId, sampleByEvent, sampleBySessionId } from '../../customizations/before-send' + +beforeAll(() => { + let fiftyFiftyRandom = true + Math.random = () => { + const val = fiftyFiftyRandom ? 0.48 : 0.51 + fiftyFiftyRandom = !fiftyFiftyRandom + return val + } +}) + +describe('before send utils', () => { + it('can sample by event name', () => { + const sampleFn = sampleByEvent(['$autocapture'], 0.5) + + const results = [] + Array.from({ length: 100 }).forEach(() => { + const captureResult = { event: '$autocapture' } as unknown as CaptureResult + results.push(sampleFn(captureResult)) + }) + const emittedEvents = results.filter((r) => !isNull(r)) + + expect(emittedEvents.length).toBe(50) + expect(emittedEvents[0].properties).toMatchObject({ + $sample_type: ['sampleByEvent'], + $sample_threshold: 0.5, + $sampled_events: ['$autocapture'], + }) + }) + + it('can sample by distinct id', () => { + const sampleFn = sampleByDistinctId(0.5) + const results = [] + const distinct_id_one = 'user-1' + const distinct_id_two = 'user-that-hashes-to-no-events' + Array.from({ length: 100 }).forEach(() => { + ;[distinct_id_one, distinct_id_two].forEach((distinct_id) => { + const captureResult = { properties: { distinct_id } } as unknown as CaptureResult + results.push(sampleFn(captureResult)) + }) + }) + const distinctIdOneEvents = results.filter((r) => !isNull(r) && r.properties.distinct_id === distinct_id_one) + const distinctIdTwoEvents = results.filter((r) => !isNull(r) && r.properties.distinct_id === distinct_id_two) + + expect(distinctIdOneEvents.length).toBe(100) + expect(distinctIdTwoEvents.length).toBe(0) + + expect(distinctIdOneEvents[0].properties).toMatchObject({ + $sample_type: ['sampleByDistinctId'], + $sample_threshold: 0.5, + }) + }) + + it('can sample by session id', () => { + const sampleFn = sampleBySessionId(0.5) + const results = [] + const session_id_one = 'a-session-id' + const session_id_two = 'id-that-hashes-to-not-sending-events' + Array.from({ length: 100 }).forEach(() => { + ;[session_id_one, session_id_two].forEach((session_id) => { + const captureResult = { properties: { $session_id: session_id } } as unknown as CaptureResult + results.push(sampleFn(captureResult)) + }) + }) + const sessionIdOneEvents = results.filter((r) => !isNull(r) && r.properties.$session_id === session_id_one) + const sessionIdTwoEvents = results.filter((r) => !isNull(r) && r.properties.$session_id === session_id_two) + + expect(sessionIdOneEvents.length).toBe(100) + expect(sessionIdTwoEvents.length).toBe(0) + + expect(sessionIdOneEvents[0].properties).toMatchObject({ + $sample_type: ['sampleBySessionId'], + $sample_threshold: 0.5, + }) + }) + + it('can combine thresholds', () => { + const sampleBySession = sampleBySessionId(0.5) + const sampleByEventFn = sampleByEvent(['$autocapture'], 0.5) + + const results = [] + const session_id_one = 'a-session-id' + const session_id_two = 'id-that-hashes-to-not-sending-events' + Array.from({ length: 100 }).forEach(() => { + ;[session_id_one, session_id_two].forEach((session_id) => { + const captureResult = { + event: '$autocapture', + properties: { $session_id: session_id }, + } as unknown as CaptureResult + const firstBySession = sampleBySession(captureResult) + const thenByEvent = sampleByEventFn(firstBySession) + results.push(thenByEvent) + }) + }) + const sessionIdOneEvents = results.filter((r) => !isNull(r) && r.properties.$session_id === session_id_one) + const sessionIdTwoEvents = results.filter((r) => !isNull(r) && r.properties.$session_id === session_id_two) + + expect(sessionIdOneEvents.length).toBe(50) + expect(sessionIdTwoEvents.length).toBe(0) + + expect(sessionIdOneEvents[0].properties).toMatchObject({ + $sample_type: ['sampleBySessionId', 'sampleByEvent'], + $sample_threshold: 0.25, + }) + }) +}) diff --git a/src/autocapture.ts b/src/autocapture.ts index 8fc25d580..cf9289e3a 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -15,7 +15,7 @@ import { splitClassString, } from './autocapture-utils' import RageClick from './extensions/rageclick' -import { AutocaptureConfig, DecideResponse, Properties } from './types' +import { AutocaptureConfig, COPY_AUTOCAPTURE_EVENT, DecideResponse, EventName, Properties } from './types' import { PostHog } from './posthog-core' import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from './constants' @@ -25,8 +25,6 @@ import { document, window } from './utils/globals' import { convertToURL } from './utils/request-utils' import { isDocumentFragment, isElementNode, isTag, isTextNode } from './utils/element-utils' -const COPY_AUTOCAPTURE_EVENT = '$copy_autocapture' - function limitText(length: number, text: string): string { if (text.length > length) { return text.slice(0, length) + '...' @@ -343,7 +341,7 @@ export class Autocapture { return !disabledClient && !disabledServer } - private _captureEvent(e: Event, eventName = '$autocapture'): boolean | void { + private _captureEvent(e: Event, eventName: EventName = '$autocapture'): boolean | void { if (!this.isEnabled) { return } diff --git a/src/config.ts b/src/config.ts index 3c4adce82..51ddf6941 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,10 @@ -import { version } from '../package.json' +import packageInfo from '../package.json' // overridden in posthog-core, // e.g. Config.DEBUG = Config.DEBUG || instance.config.debug const Config = { DEBUG: false, - LIB_VERSION: version, + LIB_VERSION: packageInfo.version, } export default Config diff --git a/src/constants.ts b/src/constants.ts index f86c52ee4..594f1503b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,7 +13,6 @@ export const EVENT_TIMERS_KEY = '__timers' export const AUTOCAPTURE_DISABLED_SERVER_SIDE = '$autocapture_disabled_server_side' export const HEATMAPS_ENABLED_SERVER_SIDE = '$heatmaps_enabled_server_side' export const EXCEPTION_CAPTURE_ENABLED_SERVER_SIDE = '$exception_capture_enabled_server_side' -export const EXCEPTION_CAPTURE_ENDPOINT_SUFFIX = '$exception_capture_endpoint_suffix' export const WEB_VITALS_ENABLED_SERVER_SIDE = '$web_vitals_enabled_server_side' export const DEAD_CLICKS_ENABLED_SERVER_SIDE = '$dead_clicks_enabled_server_side' export const WEB_VITALS_ALLOWED_METRICS = '$web_vitals_allowed_metrics' @@ -27,6 +26,8 @@ export const SESSION_ID = '$sesid' export const SESSION_RECORDING_IS_SAMPLED = '$session_is_sampled' export const SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION = '$session_recording_url_trigger_activated_session' export const SESSION_RECORDING_URL_TRIGGER_STATUS = '$session_recording_url_trigger_status' +export const SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION = '$session_recording_event_trigger_activated_session' +export const SESSION_RECORDING_EVENT_TRIGGER_STATUS = '$session_recording_event_trigger_status' export const ENABLED_FEATURE_FLAGS = '$enabled_feature_flags' export const PERSISTENCE_EARLY_ACCESS_FEATURES = '$early_access_features' export const STORED_PERSON_PROPERTIES_KEY = '$stored_person_properties' diff --git a/src/customizations/before-send.ts b/src/customizations/before-send.ts new file mode 100644 index 000000000..0f28c4d4a --- /dev/null +++ b/src/customizations/before-send.ts @@ -0,0 +1,119 @@ +import { clampToRange } from '../utils/number-utils' +import { BeforeSendFn, CaptureResult, KnownEventName } from '../types' +import { includes } from '../utils' +import { isArray, isUndefined } from '../utils/type-utils' + +function appendArray(currentValue: string[] | undefined, sampleType: string | string[]): string[] { + return [...(currentValue ? currentValue : []), ...(isArray(sampleType) ? sampleType : [sampleType])] +} + +function updateThreshold(currentValue: number | undefined, percent: number): number { + return (isUndefined(currentValue) ? 1 : currentValue) * percent +} + +function simpleHash(str: string) { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i) // (hash * 31) + char code + hash |= 0 // Convert to 32bit integer + } + return Math.abs(hash) +} + +/* + * receives percent as a number between 0 and 1 + */ +function sampleOnProperty(prop: string, percent: number): boolean { + return simpleHash(prop) % 100 < clampToRange(percent * 100, 0, 100) +} + +/** + * Provides an implementation of sampling that samples based on the distinct ID. + * Using the provided percentage. + * Can be used to create a beforeCapture fn for a PostHog instance. + * + * Setting 0.5 will cause roughly 50% of distinct ids to have events sent. + * Not 50% of events for each distinct id. + * + * @param percent a number from 0 to 1, 1 means always send and, 0 means never send the event + */ +export function sampleByDistinctId(percent: number): BeforeSendFn { + return (captureResult: CaptureResult | null): CaptureResult | null => { + if (!captureResult) { + return null + } + + return sampleOnProperty(captureResult.properties.distinct_id, percent) + ? { + ...captureResult, + properties: { + ...captureResult.properties, + $sample_type: ['sampleByDistinctId'], + $sample_threshold: percent, + }, + } + : null + } +} + +/** + * Provides an implementation of sampling that samples based on the session ID. + * Using the provided percentage. + * Can be used to create a beforeCapture fn for a PostHog instance. + * + * Setting 0.5 will cause roughly 50% of sessions to have events sent. + * Not 50% of events for each session. + * + * @param percent a number from 0 to 1, 1 means always send and, 0 means never send the event + */ +export function sampleBySessionId(percent: number): BeforeSendFn { + return (captureResult: CaptureResult | null): CaptureResult | null => { + if (!captureResult) { + return null + } + + return sampleOnProperty(captureResult.properties.$session_id, percent) + ? { + ...captureResult, + properties: { + ...captureResult.properties, + $sample_type: appendArray(captureResult.properties.$sample_type, 'sampleBySessionId'), + $sample_threshold: updateThreshold(captureResult.properties.$sample_threshold, percent), + }, + } + : null + } +} + +/** + * Provides an implementation of sampling that samples based on the event name. + * Using the provided percentage. + * Can be used to create a beforeCapture fn for a PostHog instance. + * + * @param eventNames an array of event names to sample, sampling is applied across events not per event name + * @param percent a number from 0 to 1, 1 means always send, 0 means never send the event + */ +export function sampleByEvent(eventNames: KnownEventName[], percent: number): BeforeSendFn { + return (captureResult: CaptureResult | null): CaptureResult | null => { + if (!captureResult) { + return null + } + + if (!includes(eventNames, captureResult.event)) { + return captureResult + } + + const number = Math.random() + return number * 100 < clampToRange(percent * 100, 0, 100) + ? { + ...captureResult, + properties: { + ...captureResult.properties, + $sample_type: appendArray(captureResult.properties?.$sample_type, 'sampleByEvent'), + $sample_threshold: updateThreshold(captureResult.properties?.$sample_threshold, percent), + $sampled_events: appendArray(captureResult.properties?.$sampled_events, eventNames), + }, + } + : null + } +} diff --git a/src/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.ts b/src/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.ts new file mode 100644 index 000000000..42b7f52f9 --- /dev/null +++ b/src/customizations/setAllPersonProfilePropertiesAsPersonPropertiesForFlags.ts @@ -0,0 +1,15 @@ +import { PostHog } from '../posthog-core' +import { CAMPAIGN_PARAMS, EVENT_TO_PERSON_PROPERTIES, Info } from '../utils/event-utils' +import { each, extend, includes } from '../utils' + +export const setAllPersonProfilePropertiesAsPersonPropertiesForFlags = (posthog: PostHog): void => { + const allProperties = extend({}, Info.properties(), Info.campaignParams(), Info.referrerInfo()) + const personProperties: Record = {} + each(allProperties, function (v, k: string) { + if (includes(CAMPAIGN_PARAMS, k) || includes(EVENT_TO_PERSON_PROPERTIES, k)) { + personProperties[k] = v + } + }) + + posthog.setPersonPropertiesForFlags(personProperties) +} diff --git a/src/entrypoints/dead-clicks-autocapture.ts b/src/entrypoints/dead-clicks-autocapture.ts index bb4c5d1a1..496879686 100644 --- a/src/entrypoints/dead-clicks-autocapture.ts +++ b/src/entrypoints/dead-clicks-autocapture.ts @@ -5,6 +5,7 @@ import { autocaptureCompatibleElements, getEventTarget } from '../autocapture-ut import { DeadClickCandidate, DeadClicksAutoCaptureConfig, Properties } from '../types' import { autocapturePropertiesForElement } from '../autocapture' import { isElementInToolbar, isElementNode, isTag } from '../utils/element-utils' +import { getNativeMutationObserverImplementation } from '../utils/prototype-utils' function asClick(event: MouseEvent): DeadClickCandidate | null { const eventTarget = getEventTarget(event) @@ -66,7 +67,8 @@ class LazyLoadedDeadClicksAutocapture implements LazyLoadedDeadClicksAutocapture private _startMutationObserver(observerTarget: Node) { if (!this._mutationObserver) { - this._mutationObserver = new MutationObserver((mutations) => { + const NativeMutationObserver = getNativeMutationObserverImplementation(assignableWindow) + this._mutationObserver = new NativeMutationObserver((mutations) => { this.onMutation(mutations) }) this._mutationObserver.observe(observerTarget, { diff --git a/src/entrypoints/recorder.ts b/src/entrypoints/recorder.ts index 134b252ff..ffda9496a 100644 --- a/src/entrypoints/recorder.ts +++ b/src/entrypoints/recorder.ts @@ -309,7 +309,7 @@ function initXhrObserver(cb: networkCallback, win: IWindow, options: Required { const requests = prepareRequest({ entry, - method: req.method, + method: method, status: xhr?.status, networkRequest, start, @@ -386,7 +386,7 @@ function prepareRequest({ timeOrigin, timestamp, method: method, - initiatorType: entry ? (entry.initiatorType as InitiatorType) : initiatorType, + initiatorType: initiatorType ? initiatorType : entry ? (entry.initiatorType as InitiatorType) : undefined, status, requestHeaders: networkRequest.requestHeaders, requestBody: networkRequest.requestBody, diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index f4354d1eb..bfa2693a9 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -2,12 +2,12 @@ import { CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE, SESSION_RECORDING_CANVAS_RECORDING, SESSION_RECORDING_ENABLED_SERVER_SIDE, + SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION, SESSION_RECORDING_IS_SAMPLED, SESSION_RECORDING_MINIMUM_DURATION, SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE, SESSION_RECORDING_SAMPLE_RATE, SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION, - SESSION_RECORDING_URL_TRIGGER_STATUS, } from '../../constants' import { estimateSize, @@ -19,6 +19,7 @@ import { } from './sessionrecording-utils' import { PostHog } from '../../posthog-core' import { + CaptureResult, DecideResponse, FlagVariant, NetworkRecordOptions, @@ -43,14 +44,17 @@ import { isLocalhost } from '../../utils/request-utils' import { MutationRateLimiter } from './mutation-rate-limiter' import { gzipSync, strFromU8, strToU8 } from 'fflate' import { clampToRange } from '../../utils/number-utils' +import { includes } from '../../utils' type SessionStartReason = - | 'sampling_override' + | 'sampling_overridden' | 'recording_initialized' - | 'linked_flag_match' - | 'linked_flag_override' - | 'sampling' + | 'linked_flag_matched' + | 'linked_flag_overridden' + | 'sampled' | 'session_id_changed' + | 'url_trigger_matched' + | 'event_trigger_matched' const BASE_ENDPOINT = '/s/' @@ -75,8 +79,8 @@ const ACTIVE_SOURCES = [ IncrementalSource.Drag, ] -const TRIGGER_STATUSES = ['trigger_activated', 'trigger_pending', 'trigger_disabled'] as const -type TriggerStatus = typeof TRIGGER_STATUSES[number] +export type TriggerType = 'url' | 'event' +type TriggerStatus = 'trigger_activated' | 'trigger_pending' | 'trigger_disabled' /** * Session recording starts in buffering mode while waiting for decide response @@ -266,6 +270,9 @@ export class SessionRecording { private _urlBlocked: boolean = false + private _eventTriggers: string[] = [] + private _removeEventTriggerCaptureHook: (() => void) | undefined = undefined + // Util to help developers working on this feature manually override _forceAllowLocalhostNetworkCapture = false @@ -291,7 +298,7 @@ export class SessionRecording { } private get fullSnapshotIntervalMillis(): number { - if (this.urlTriggerStatus === 'trigger_pending') { + if (this.triggerStatus === 'trigger_pending') { return ONE_MINUTE } @@ -389,7 +396,7 @@ export class SessionRecording { return 'buffering' } - if (this.urlTriggerStatus === 'trigger_pending') { + if (this.triggerStatus === 'trigger_pending') { return 'buffering' } @@ -409,27 +416,25 @@ export class SessionRecording { return 'trigger_disabled' } - const currentStatus = this.instance?.get_property(SESSION_RECORDING_URL_TRIGGER_STATUS) const currentTriggerSession = this.instance?.get_property(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) + return currentTriggerSession === this.sessionId ? 'trigger_activated' : 'trigger_pending' + } - if (currentTriggerSession !== this.sessionId) { - this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) - this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_STATUS) - return 'trigger_pending' - } - - if (TRIGGER_STATUSES.includes(currentStatus)) { - return currentStatus as TriggerStatus + private get eventTriggerStatus(): TriggerStatus { + if (this._eventTriggers.length === 0) { + return 'trigger_disabled' } - return 'trigger_pending' + const currentTriggerSession = this.instance?.get_property(SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION) + return currentTriggerSession === this.sessionId ? 'trigger_activated' : 'trigger_pending' } - private set urlTriggerStatus(status: TriggerStatus) { - this.instance?.persistence?.register({ - [SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION]: this.sessionId, - [SESSION_RECORDING_URL_TRIGGER_STATUS]: status, - }) + private get triggerStatus(): TriggerStatus { + const eitherIsActivated = + this.eventTriggerStatus === 'trigger_activated' || this.urlTriggerStatus === 'trigger_activated' + const eitherIsPending = + this.eventTriggerStatus === 'trigger_pending' || this.urlTriggerStatus === 'trigger_pending' + return eitherIsActivated ? 'trigger_activated' : eitherIsPending ? 'trigger_pending' : 'trigger_disabled' } constructor(private readonly instance: PostHog) { @@ -491,6 +496,8 @@ export class SessionRecording { // so we call this here _and_ in the decide response this._setupSampling() + this._addEventTriggerListener() + if (isNullish(this._removePageViewCaptureHook)) { // :TRICKY: rrweb does not capture navigation within SPA-s, so hook into our $pageview events to get access to all events. // Dropping the initial event is fine (it's always captured by rrweb). @@ -516,8 +523,8 @@ export class SessionRecording { if (changeReason) { this._tryAddCustomEvent('$session_id_change', { sessionId, windowId, changeReason }) + this.instance?.persistence?.unregister(SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION) this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) - this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_STATUS) } }) } @@ -542,6 +549,8 @@ export class SessionRecording { this._removePageViewCaptureHook?.() this._removePageViewCaptureHook = undefined + this._removeEventTriggerCaptureHook?.() + this._removeEventTriggerCaptureHook = undefined this._onSessionIdListener?.() this._onSessionIdListener = undefined this._samplingSessionListener?.() @@ -586,7 +595,7 @@ export class SessionRecording { if (makeDecision) { if (shouldSample) { - this._reportStarted('sampling') + this._reportStarted('sampled') } else { logger.warn( LOGGER_PREFIX + @@ -623,14 +632,10 @@ export class SessionRecording { const flagIsPresent = isObject(variants) && linkedFlag in variants const linkedFlagMatches = linkedVariant ? variants[linkedFlag] === linkedVariant : flagIsPresent if (linkedFlagMatches) { - const payload = { + this._reportStarted('linked_flag_matched', { linkedFlag, linkedVariant, - } - const tag = 'linked flag matched' - logger.info(LOGGER_PREFIX + ' ' + tag, payload) - this._tryAddCustomEvent(tag, payload) - this._reportStarted('linked_flag_match') + }) } this._linkedFlagSeen = linkedFlagMatches }) @@ -644,6 +649,10 @@ export class SessionRecording { this._urlBlocklist = response.sessionRecording.urlBlocklist } + if (response.sessionRecording?.eventTriggers) { + this._eventTriggers = response.sessionRecording.eventTriggers + } + this.receivedDecide = true this.startIfEnabledOrStop() } @@ -1012,7 +1021,7 @@ export class SessionRecording { } // Check if the URL matches any trigger patterns - this._checkTriggerConditions() + this._checkUrlTriggerConditions() if (this.status === 'paused' && !isRecordingPausedEvent(rawEvent)) { return @@ -1024,7 +1033,7 @@ export class SessionRecording { } // Clear the buffer if waiting for a trigger, and only keep data from after the current full snapshot - if (rawEvent.type === EventType.FullSnapshot && this.urlTriggerStatus === 'trigger_pending') { + if (rawEvent.type === EventType.FullSnapshot && this.triggerStatus === 'trigger_pending') { this.clearBuffer() } @@ -1205,7 +1214,7 @@ export class SessionRecording { }) } - private _checkTriggerConditions() { + private _checkUrlTriggerConditions() { if (typeof window === 'undefined' || !window.location.href) { return } @@ -1222,16 +1231,21 @@ export class SessionRecording { } if (sessionRecordingUrlTriggerMatches(url, this._urlTriggers)) { - this._activateUrlTrigger() + this._activateTrigger('url') } } - private _activateUrlTrigger() { - if (this.urlTriggerStatus === 'trigger_pending') { - this.urlTriggerStatus = 'trigger_activated' - this._tryAddCustomEvent('url trigger activated', {}) + private _activateTrigger(triggerType: TriggerType) { + if (this.triggerStatus === 'trigger_pending') { + // status is stored separately for URL and event triggers + this.instance?.persistence?.register({ + [triggerType === 'url' + ? SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION + : SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION]: this.sessionId, + }) + this._flushBuffer() - logger.info(LOGGER_PREFIX + ' recording triggered by URL pattern match') + this._reportStarted((triggerType + '_trigger_matched') as SessionStartReason) } } @@ -1239,9 +1253,6 @@ export class SessionRecording { if (this.status === 'paused') { return } - logger.info(LOGGER_PREFIX + ' recording paused due to URL blocker') - - this._tryAddCustomEvent('recording paused', { reason: 'url blocker' }) this._urlBlocked = true document?.body?.classList?.add('ph-no-capture') @@ -1253,6 +1264,9 @@ export class SessionRecording { setTimeout(() => { this._flushBuffer() }, 100) + + logger.info(LOGGER_PREFIX + ' recording paused due to URL blocker') + this._tryAddCustomEvent('recording paused', { reason: 'url blocker' }) } private _resumeRecording() { @@ -1264,27 +1278,43 @@ export class SessionRecording { document?.body?.classList?.remove('ph-no-capture') this._tryTakeFullSnapshot() - this._scheduleFullSnapshot() + this._tryAddCustomEvent('recording resumed', { reason: 'left blocked url' }) logger.info(LOGGER_PREFIX + ' recording resumed') } + private _addEventTriggerListener() { + if (this._eventTriggers.length === 0 || !isNullish(this._removeEventTriggerCaptureHook)) { + return + } + + this._removeEventTriggerCaptureHook = this.instance.on('eventCaptured', (event: CaptureResult) => { + // If anything could go wrong here it has the potential to block the main loop, + // so we catch all errors. + try { + if (this._eventTriggers.includes(event.event)) { + this._activateTrigger('event') + } + } catch (e) { + logger.error(LOGGER_PREFIX + 'Could not activate event trigger', e) + } + }) + } + /** - * this ignores the linked flag config and causes capture to start - * (if recording would have started had the flag been received i.e. it does not override other config). + * this ignores the linked flag config and (if other conditions are met) causes capture to start * * It is not usual to call this directly, * instead call `posthog.startSessionRecording({linked_flag: true})` * */ public overrideLinkedFlag() { this._linkedFlagSeen = true - this._reportStarted('linked_flag_override') + this._reportStarted('linked_flag_overridden') } /** - * this ignores the sampling config and causes capture to start - * (if recording would have started had the flag been received i.e. it does not override other config). + * this ignores the sampling config and (if other conditions are met) causes capture to start * * It is not usual to call this directly, * instead call `posthog.startSessionRecording({sampling: true})` @@ -1294,14 +1324,26 @@ export class SessionRecording { // short-circuits the `makeSamplingDecision` function in the session recording module [SESSION_RECORDING_IS_SAMPLED]: true, }) - this._reportStarted('sampling_override') + this._reportStarted('sampling_overridden') } - private _reportStarted(startReason: SessionStartReason, shouldReport: () => boolean = () => true) { - if (shouldReport()) { - this.instance.register_for_session({ - $session_recording_start_reason: startReason, - }) + /** + * this ignores the URL/Event trigger config and (if other conditions are met) causes capture to start + * + * It is not usual to call this directly, + * instead call `posthog.startSessionRecording({trigger: 'url' | 'event'})` + * */ + public overrideTrigger(triggerType: TriggerType) { + this._activateTrigger(triggerType) + } + + private _reportStarted(startReason: SessionStartReason, tagPayload?: Record) { + this.instance.register_for_session({ + $session_recording_start_reason: startReason, + }) + logger.info(LOGGER_PREFIX + ' ' + startReason.replace('_', ' '), tagPayload) + if (!includes(['recording_initialized', 'session_id_changed'], startReason)) { + this._tryAddCustomEvent(startReason, tagPayload) } } } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 6e2bc3e7c..db362d2af 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -34,6 +34,7 @@ import { Compression, DecideResponse, EarlyAccessFeatureCallback, + EventName, IsFeatureEnabledOptions, JsonType, PostHogConfig, @@ -54,10 +55,11 @@ import { uuidv7 } from './uuidv7' import { Survey, SurveyCallback, SurveyQuestionBranchingType } from './posthog-surveys-types' import { isArray, - isBoolean, isEmptyObject, isEmptyString, isFunction, + isKnownUnsafeEditableEvent, + isNullish, isNumber, isObject, isString, @@ -181,6 +183,7 @@ export const defaultConfig = (): PostHogConfig => ({ session_idle_timeout_seconds: 30 * 60, // 30 minutes person_profiles: 'identified_only', __add_tracing_headers: false, + before_send: undefined, }) export const configRenames = (origConfig: Partial): Partial => { @@ -412,7 +415,7 @@ export class PostHog { this.persistence = new PostHogPersistence(this.config) this.sessionPersistence = - this.config.persistence === 'sessionStorage' + this.config.persistence === 'sessionStorage' || this.config.persistence === 'memory' ? this.persistence : new PostHogPersistence({ ...this.config, persistence: 'sessionStorage' }) @@ -528,7 +531,8 @@ export class PostHog { this._loaded() } - if (isFunction(this.config._onCapture)) { + if (isFunction(this.config._onCapture) && this.config._onCapture !== __NOOP) { + logger.warn('onCapture is deprecated. Please use `before_send` instead') this.on('eventCaptured', (data) => this.config._onCapture(data.event, data)) } @@ -564,7 +568,6 @@ export class PostHog { this.experiments?.afterDecideResponse(response) this.surveys?.afterDecideResponse(response) this.webVitalsAutocapture?.afterDecideResponse(response) - this.exceptions?.afterDecideResponse(response) this.exceptionObserver?.afterDecideResponse(response) this.deadClicksAutocapture?.afterDecideResponse(response) } @@ -787,7 +790,11 @@ export class PostHog { * @param {String} [config.transport] Transport method for network request ('XHR' or 'sendBeacon'). * @param {Date} [config.timestamp] Timestamp is a Date object. If not set, it'll automatically be set to the current time. */ - capture(event_name: string, properties?: Properties | null, options?: CaptureOptions): CaptureResult | undefined { + capture( + event_name: EventName, + properties?: Properties | null, + options?: CaptureOptions + ): CaptureResult | undefined { // While developing, a developer might purposefully _not_ call init(), // in this case, we would like capture to be a noop. if (!this.__loaded || !this.persistence || !this.sessionPersistence || !this._requestQueue) { @@ -870,6 +877,15 @@ export class PostHog { this.setPersonPropertiesForFlags(finalSet) } + if (!isNullish(this.config.before_send)) { + const beforeSendResult = this._runBeforeSend(data) + if (!beforeSendResult) { + return + } else { + data = beforeSendResult + } + } + this._internalEventEmitter.emit('eventCaptured', data) const requestOptions: QueuedRequestOptions = { @@ -1786,7 +1802,7 @@ export class PostHog { this.persistence?.update_config(this.config, oldConfig) this.sessionPersistence = - this.config.persistence === 'sessionStorage' + this.config.persistence === 'sessionStorage' || this.config.persistence === 'memory' ? this.persistence : new PostHogPersistence({ ...this.config, persistence: 'sessionStorage' }) @@ -1814,22 +1830,42 @@ export class PostHog { * turns session recording on, and updates the config option `disable_session_recording` to false * @param override.sampling - optional boolean to override the default sampling behavior - ensures the next session recording to start will not be skipped by sampling config. * @param override.linked_flag - optional boolean to override the default linked_flag behavior - ensures the next session recording to start will not be skipped by linked_flag config. + * @param override.url_trigger - optional boolean to override the default url_trigger behavior - ensures the next session recording to start will not be skipped by url_trigger config. + * @param override.event_trigger - optional boolean to override the default event_trigger behavior - ensures the next session recording to start will not be skipped by event_trigger config. * @param override - optional boolean to override the default sampling behavior - ensures the next session recording to start will not be skipped by sampling or linked_flag config. `true` is shorthand for { sampling: true, linked_flag: true } */ - startSessionRecording(override?: { sampling?: boolean; linked_flag?: boolean } | true): void { - const overrideAll = isBoolean(override) && override - if (overrideAll || override?.sampling || override?.linked_flag) { + startSessionRecording( + override?: { sampling?: boolean; linked_flag?: boolean; url_trigger?: true; event_trigger?: true } | true + ): void { + const overrideAll = override === true + const overrideConfig = { + sampling: overrideAll || !!override?.sampling, + linked_flag: overrideAll || !!override?.linked_flag, + url_trigger: overrideAll || !!override?.url_trigger, + event_trigger: overrideAll || !!override?.event_trigger, + } + + if (Object.values(overrideConfig).some(Boolean)) { // allow the session id check to rotate session id if necessary - const ids = this.sessionManager?.checkAndGetSessionAndWindowId() - if (overrideAll || override?.sampling) { + this.sessionManager?.checkAndGetSessionAndWindowId() + + if (overrideConfig.sampling) { this.sessionRecording?.overrideSampling() - logger.info('Session recording started with sampling override for session: ', ids?.sessionId) } - if (overrideAll || override?.linked_flag) { + + if (overrideConfig.linked_flag) { this.sessionRecording?.overrideLinkedFlag() - logger.info('Session recording started with linked_flags override') + } + + if (overrideConfig.url_trigger) { + this.sessionRecording?.overrideTrigger('url') + } + + if (overrideConfig.event_trigger) { + this.sessionRecording?.overrideTrigger('event') } } + this.set_config({ disable_session_recording: false }) } @@ -2040,7 +2076,7 @@ export class PostHog { * @param {Object} [config.capture_properties] Set of properties to be captured along with the opt-in action */ opt_in_capturing(options?: { - captureEventName?: string | null | false /** event name to be used for capturing the opt-in action */ + captureEventName?: EventName | null | false /** event name to be used for capturing the opt-in action */ captureProperties?: Properties /** set of properties to be captured along with the opt-in action */ }): void { this.consent.optInOut(true) @@ -2141,6 +2177,33 @@ export class PostHog { this.set_config({ debug: true }) } } + + private _runBeforeSend(data: CaptureResult): CaptureResult | null { + if (isNullish(this.config.before_send)) { + return data + } + + const fns = isArray(this.config.before_send) ? this.config.before_send : [this.config.before_send] + let beforeSendResult: CaptureResult | null = data + for (const fn of fns) { + beforeSendResult = fn(beforeSendResult) + if (isNullish(beforeSendResult)) { + const logMessage = `Event '${data.event}' was rejected in beforeSend function` + if (isKnownUnsafeEditableEvent(data.event)) { + logger.warn(`${logMessage}. This can cause unexpected behavior.`) + } else { + logger.info(logMessage) + } + return null + } + if (!beforeSendResult.properties || isEmptyObject(beforeSendResult.properties)) { + logger.warn( + `Event '${data.event}' has no properties after beforeSend function, this is likely an error.` + ) + } + } + return beforeSendResult + } } safewrapClass(PostHog, ['identify']) diff --git a/src/posthog-exceptions.ts b/src/posthog-exceptions.ts index f8cc687a2..d964ecc10 100644 --- a/src/posthog-exceptions.ts +++ b/src/posthog-exceptions.ts @@ -1,41 +1,8 @@ -import { EXCEPTION_CAPTURE_ENDPOINT_SUFFIX } from './constants' import { PostHog } from './posthog-core' -import { DecideResponse, Properties } from './types' -import { isObject } from './utils/type-utils' - -// TODO: move this to /x/ as default -export const BASE_ERROR_ENDPOINT_SUFFIX = '/e/' +import { Properties } from './types' export class PostHogExceptions { - private _endpointSuffix: string - - constructor(private readonly instance: PostHog) { - // TODO: once BASE_ERROR_ENDPOINT_SUFFIX is no longer /e/ this can be removed - this._endpointSuffix = - this.instance.persistence?.props[EXCEPTION_CAPTURE_ENDPOINT_SUFFIX] || BASE_ERROR_ENDPOINT_SUFFIX - } - - get endpoint() { - // Always respect any api_host set by the client config - return this.instance.requestRouter.endpointFor('api', this._endpointSuffix) - } - - afterDecideResponse(response: DecideResponse) { - const autocaptureExceptionsResponse = response.autocaptureExceptions - - this._endpointSuffix = isObject(autocaptureExceptionsResponse) - ? autocaptureExceptionsResponse.endpoint || BASE_ERROR_ENDPOINT_SUFFIX - : BASE_ERROR_ENDPOINT_SUFFIX - - if (this.instance.persistence) { - // when we come to moving the endpoint to not /e/ - // we'll want that to persist between startup and decide response - // TODO: once BASE_ENDPOINT is no longer /e/ this can be removed - this.instance.persistence.register({ - [EXCEPTION_CAPTURE_ENDPOINT_SUFFIX]: this._endpointSuffix, - }) - } - } + constructor(private readonly instance: PostHog) {} /** * :TRICKY: Make sure we batch these requests @@ -44,7 +11,6 @@ export class PostHogExceptions { this.instance.capture('$exception', properties, { _noTruncate: true, _batchKey: 'exceptionEvent', - _url: this.endpoint, }) } } diff --git a/src/types.ts b/src/types.ts index d54c161c1..d7994968a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,9 +5,60 @@ import { recordOptions } from './extensions/replay/sessionrecording-utils' export type Property = any export type Properties = Record +export const COPY_AUTOCAPTURE_EVENT = '$copy_autocapture' + +export const knownUnsafeEditableEvent = [ + '$snapshot', + '$pageview', + '$pageleave', + '$set', + 'survey dismissed', + 'survey sent', + 'survey shown', + '$identify', + '$groupidentify', + '$create_alias', + '$$client_ingestion_warning', + '$web_experiment_applied', + '$feature_enrollment_update', + '$feature_flag_called', +] as const + +/** + * These events can be processed by the `beforeCapture` function + * but can cause unexpected confusion in data. + * + * Some features of PostHog rely on receiving 100% of these events + */ +export type KnownUnsafeEditableEvent = typeof knownUnsafeEditableEvent[number] + +/** + * These are known events PostHog events that can be processed by the `beforeCapture` function + * That means PostHog functionality does not rely on receiving 100% of these for calculations + * So, it is safe to sample them to reduce the volume of events sent to PostHog + */ +export type KnownEventName = + | '$heatmaps_data' + | '$opt_in' + | '$exception' + | '$$heatmap' + | '$web_vitals' + | '$dead_click' + | '$autocapture' + | typeof COPY_AUTOCAPTURE_EVENT + | '$rageclick' + +export type EventName = + | KnownUnsafeEditableEvent + | KnownEventName + // magic value so that the type of EventName is a set of known strings or any other string + // which means you get autocomplete for known strings + // but no type complaints when you add an arbitrary string + | (string & {}) + export interface CaptureResult { uuid: string - event: string + event: EventName properties: Properties $set?: Properties $set_once?: Properties @@ -162,6 +213,8 @@ export interface HeatmapConfig { flush_interval_milliseconds: number } +export type BeforeSendFn = (cr: CaptureResult | null) => CaptureResult | null + export interface PostHogConfig { api_host: string /** @deprecated - This property is no longer supported */ @@ -237,7 +290,18 @@ export interface PostHogConfig { feature_flag_request_timeout_ms: number get_device_id: (uuid: string) => string name: string + /** + * this is a read-only function that can be used to react to event capture + * @deprecated - use `before_send` instead - NB before_send is not read only + */ _onCapture: (eventName: string, eventData: CaptureResult) => void + /** + * This function or array of functions - if provided - are called immediately before sending data to the server. + * It allows you to edit data before it is sent, or choose not to send it all. + * if provided as an array the functions are called in the order they are provided + * any one function returning null means the event will not be sent + */ + before_send?: BeforeSendFn | BeforeSendFn[] capture_performance?: boolean | PerformanceCaptureConfig // Should only be used for testing. Could negatively impact performance. disable_compression: boolean @@ -452,6 +516,7 @@ export interface DecideResponse { networkPayloadCapture?: Pick urlTriggers?: SessionRecordingUrlTrigger[] urlBlocklist?: SessionRecordingUrlTrigger[] + eventTriggers?: string[] } surveys?: boolean toolbarParams: ToolbarParams @@ -670,7 +735,6 @@ export interface ErrorConversions { } export interface SessionRecordingUrlTrigger { - urlBlockList?: SessionRecordingUrlTrigger[] url: string matching: 'regex' } diff --git a/src/utils/event-utils.ts b/src/utils/event-utils.ts index 3f12553a7..120b3f5ba 100644 --- a/src/utils/event-utils.ts +++ b/src/utils/event-utils.ts @@ -31,6 +31,25 @@ export const CAMPAIGN_PARAMS = [ 'rdt_cid', // reddit ] +export const EVENT_TO_PERSON_PROPERTIES = [ + // mobile params + '$app_build', + '$app_name', + '$app_namespace', + '$app_version', + // web params + '$browser', + '$browser_version', + '$device_type', + '$current_url', + '$pathname', + '$os', + '$os_name', // $os_name is a special case, it's treated as an alias of $os! + '$os_version', + '$referring_domain', + '$referrer', +] + export const Info = { campaignParams: function (customParams?: string[]): Record { if (!document) { diff --git a/src/utils/globals.ts b/src/utils/globals.ts index e23e10365..377fe22b5 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -16,6 +16,12 @@ import { DeadClicksAutoCaptureConfig, ErrorEventArgs, ErrorMetadata, Properties // eslint-disable-next-line no-restricted-globals const win: (Window & typeof globalThis) | undefined = typeof window !== 'undefined' ? window : undefined +export type AssignableWindow = Window & + typeof globalThis & + Record & { + __PosthogExtensions__?: PostHogExtensions + } + /** * This is our contract between (potentially) lazily loaded extensions and the SDK * changes to this interface can be breaking changes for users of the SDK @@ -86,10 +92,6 @@ export const XMLHttpRequest = global?.XMLHttpRequest && 'withCredentials' in new global.XMLHttpRequest() ? global.XMLHttpRequest : undefined export const AbortController = global?.AbortController export const userAgent = navigator?.userAgent -export const assignableWindow: Window & - typeof globalThis & - Record & { - __PosthogExtensions__?: PostHogExtensions - } = win ?? ({} as any) +export const assignableWindow: AssignableWindow = win ?? ({} as any) export { win as window } diff --git a/src/utils/prototype-utils.ts b/src/utils/prototype-utils.ts new file mode 100644 index 000000000..5a18b8b49 --- /dev/null +++ b/src/utils/prototype-utils.ts @@ -0,0 +1,60 @@ +/** + * adapted from https://github.com/getsentry/sentry-javascript/blob/72751dacb88c5b970d8bac15052ee8e09b28fd5d/packages/browser-utils/src/getNativeImplementation.ts#L27 + * and https://github.com/PostHog/rrweb/blob/804380afbb1b9bed70b8792cb5a25d827f5c0cb5/packages/utils/src/index.ts#L31 + * after a number of performance reports from Angular users + */ + +import { AssignableWindow } from './globals' +import { isAngularZonePatchedFunction, isFunction, isNativeFunction } from './type-utils' +import { logger } from './logger' + +interface NativeImplementationsCache { + MutationObserver: typeof MutationObserver +} + +const cachedImplementations: Partial = {} + +export function getNativeImplementation( + name: T, + assignableWindow: AssignableWindow +): NativeImplementationsCache[T] { + const cached = cachedImplementations[name] + if (cached) { + return cached + } + + let impl = assignableWindow[name] as NativeImplementationsCache[T] + + if (isNativeFunction(impl) && !isAngularZonePatchedFunction(impl)) { + return (cachedImplementations[name] = impl.bind(assignableWindow) as NativeImplementationsCache[T]) + } + + const document = assignableWindow.document + if (document && isFunction(document.createElement)) { + try { + const sandbox = document.createElement('iframe') + sandbox.hidden = true + document.head.appendChild(sandbox) + const contentWindow = sandbox.contentWindow + if (contentWindow && (contentWindow as any)[name]) { + impl = (contentWindow as any)[name] as NativeImplementationsCache[T] + } + document.head.removeChild(sandbox) + } catch (e) { + // Could not create sandbox iframe, just use assignableWindow.xxx + logger.warn(`Could not create sandbox iframe for ${name} check, bailing to assignableWindow.${name}: `, e) + } + } + + // Sanity check: This _should_ not happen, but if it does, we just skip caching... + // This can happen e.g. in tests where fetch may not be available in the env, or similar. + if (!impl || !isFunction(impl)) { + return impl + } + + return (cachedImplementations[name] = impl.bind(assignableWindow) as NativeImplementationsCache[T]) +} + +export function getNativeMutationObserverImplementation(assignableWindow: AssignableWindow): typeof MutationObserver { + return getNativeImplementation('MutationObserver', assignableWindow) +} diff --git a/src/utils/type-utils.ts b/src/utils/type-utils.ts index 2d7192f23..797116c57 100644 --- a/src/utils/type-utils.ts +++ b/src/utils/type-utils.ts @@ -1,3 +1,6 @@ +import { includes } from '.' +import { knownUnsafeEditableEvent, KnownUnsafeEditableEvent } from '../types' + // eslint-disable-next-line posthog-js/no-direct-array-check const nativeIsArray = Array.isArray const ObjProto = Object.prototype @@ -9,22 +12,33 @@ export const isArray = function (obj: any): obj is any[] { return toString.call(obj) === '[object Array]' } -export const isUint8Array = function (x: unknown): x is Uint8Array { - return toString.call(x) === '[object Uint8Array]' -} + // from a comment on http://dbj.org/dbj/?p=286 // fails on only one very rare and deliberate custom object: // let bomb = { toString : undefined, valueOf: function(o) { return "function BOMBA!"; }}; -export const isFunction = function (f: any): f is (...args: any[]) => any { +export const isFunction = (x: unknown): x is (...args: any[]) => any => { // eslint-disable-next-line posthog-js/no-direct-function-check - return typeof f === 'function' + return typeof x === 'function' +} + +export const isNativeFunction = (x: unknown): x is (...args: any[]) => any => + isFunction(x) && x.toString().indexOf('[native code]') !== -1 + +// When angular patches functions they pass the above `isNativeFunction` check +export const isAngularZonePatchedFunction = (x: unknown): boolean => { + if (!isFunction(x)) { + return false + } + const prototypeKeys = Object.getOwnPropertyNames(x.prototype || {}) + return prototypeKeys.some((key) => key.indexOf('__zone')) } + // Underscore Addons -export const isObject = function (x: unknown): x is Record { +export const isObject = (x: unknown): x is Record => { // eslint-disable-next-line posthog-js/no-direct-object-check return x === Object(x) && !isArray(x) } -export const isEmptyObject = function (x: unknown): x is Record { +export const isEmptyObject = (x: unknown): x is Record => { if (isObject(x)) { for (const key in x) { if (hasOwnProperty.call(x, key)) { @@ -35,20 +49,16 @@ export const isEmptyObject = function (x: unknown): x is Record { } return false } -export const isUndefined = function (x: unknown): x is undefined { - return x === void 0 -} +export const isUndefined = (x: unknown): x is undefined => x === void 0 -export const isString = function (x: unknown): x is string { +export const isString = (x: unknown): x is string => { // eslint-disable-next-line posthog-js/no-direct-string-check return toString.call(x) == '[object String]' } -export const isEmptyString = function (x: unknown): boolean { - return isString(x) && x.trim().length === 0 -} +export const isEmptyString = (x: unknown): boolean => isString(x) && x.trim().length === 0 -export const isNull = function (x: unknown): x is null { +export const isNull = (x: unknown): x is null => { // eslint-disable-next-line posthog-js/no-direct-null-check return x === null } @@ -57,19 +67,13 @@ export const isNull = function (x: unknown): x is null { sometimes you want to check if something is null or undefined that's what this is for */ -export const isNullish = function (x: unknown): x is null | undefined { - return isUndefined(x) || isNull(x) -} +export const isNullish = (x: unknown): x is null | undefined => isUndefined(x) || isNull(x) -export const isDate = function (x: unknown): x is Date { - // eslint-disable-next-line posthog-js/no-direct-date-check - return toString.call(x) == '[object Date]' -} -export const isNumber = function (x: unknown): x is number { +export const isNumber = (x: unknown): x is number => { // eslint-disable-next-line posthog-js/no-direct-number-check return toString.call(x) == '[object Number]' } -export const isBoolean = function (x: unknown): x is boolean { +export const isBoolean = (x: unknown): x is boolean => { // eslint-disable-next-line posthog-js/no-direct-boolean-check return toString.call(x) === '[object Boolean]' } @@ -88,3 +92,7 @@ export const isFile = (x: unknown): x is File => { // eslint-disable-next-line posthog-js/no-direct-file-check return x instanceof File } + +export const isKnownUnsafeEditableEvent = (x: unknown): x is KnownUnsafeEditableEvent => { + return includes(knownUnsafeEditableEvent as unknown as string[], x) +} diff --git a/testcafe/helpers.js b/testcafe/helpers.js index 5f3312298..b1f2613cc 100644 --- a/testcafe/helpers.js +++ b/testcafe/helpers.js @@ -77,8 +77,9 @@ export const initPosthog = (testName, config) => { window.loaded = true window.fullCaptures = [] } - clientPosthogConfig._onCapture = (_, event) => { + clientPosthogConfig.before_send = (event) => { window.fullCaptures.push(event) + return event } window.posthog.init(clientPosthogConfig.api_key, clientPosthogConfig) window.posthog.register(register)