From a96b9773c77426960fe0bbdca12ffa021666e7ef Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Sat, 15 Aug 2026 23:27:09 +0700 Subject: [PATCH] [verified] privacy: remove product analytics --- app/controllers/dashboard_controller.rb | 1 - .../dashboard/helper/AnalyticsHelper/index.js | 97 ++---------- .../AnalyticsHelper/specs/helper.spec.js | 143 +++--------------- app/views/layouts/vueapp.html.erb | 7 - config/installation_config.yml | 5 - package.json | 1 - pnpm-lock.yaml | 99 ------------ spec/controllers/dashboard_controller_spec.rb | 12 ++ 8 files changed, 53 insertions(+), 312 deletions(-) diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 1dc1921b9..550c46796 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -17,7 +17,6 @@ class DashboardController < ActionController::Base CHATWOOT_INBOX_TOKEN API_CHANNEL_NAME API_CHANNEL_THUMBNAIL - CLOUD_ANALYTICS_TOKEN DIRECT_UPLOADS_ENABLED MAXIMUM_FILE_UPLOAD_SIZE HCAPTCHA_SITE_KEY diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/index.js b/app/javascript/dashboard/helper/AnalyticsHelper/index.js index e4392f473..609613788 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/index.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/index.js @@ -1,98 +1,33 @@ -import * as amplitude from '@amplitude/analytics-browser'; - +/* eslint-disable class-methods-use-this */ /** - * AnalyticsHelper class to initialize and track user analytics - * @class AnalyticsHelper + * Compatibility facade for dashboard analytics callsites. + * + * The community edition deliberately does not initialize a remote analytics + * provider or retain user, account, event, or page data. */ export class AnalyticsHelper { - /** - * @constructor - * @param {Object} [options={}] - options for analytics - * @param {string} [options.token] - analytics token - */ - constructor({ token: analyticsToken } = {}) { - this.analyticsToken = analyticsToken; + constructor() { this.analytics = null; this.user = {}; } - /** - * Initialize analytics - * @function - * @async - */ async init() { - if (!this.analyticsToken) { - return; - } - - amplitude.init(this.analyticsToken, { - defaultTracking: false, - }); - this.analytics = amplitude; + return undefined; } - /** - * Identify the user - * @function - * @param {Object} user - User object - */ - identify(user) { - if (!this.analytics || !user) { - return; - } - - this.user = user; - this.analytics.setUserId(`user-${this.user.id.toString()}`); - - const identifyEvent = new amplitude.Identify(); - identifyEvent.set('email', this.user.email); - identifyEvent.set('name', this.user.name); - identifyEvent.set('avatar', this.user.avatar_url); - this.analytics.identify(identifyEvent); - - const { accounts, account_id: accountId } = this.user; - const [currentAccount] = accounts.filter( - account => account.id === accountId - ); - if (currentAccount) { - const groupId = `account-${currentAccount.id.toString()}`; - - this.analytics.setGroup('company', groupId); - - const groupIdentify = new amplitude.Identify(); - groupIdentify.set('name', currentAccount.name); - this.analytics.groupIdentify('company', groupId, groupIdentify); - } + identify() { + return undefined; } - /** - * Track any event - * @function - * @param {string} eventName - event name - * @param {Object} [properties={}] - event properties - */ - track(eventName, properties = {}) { - if (!this.analytics) { - return; - } - this.analytics.track(eventName, properties); + track() { + return undefined; } - /** - * Track the page views - * @function - * @param {string} pageName - Page name - * @param {Object} [properties={}] - Page view properties - */ - page(pageName, properties = {}) { - if (!this.analytics) { - return; - } - - this.analytics.track('$pageview', { pageName, ...properties }); + page() { + return undefined; } } -// This object is shared across, the init is called in app/javascript/entrypoints/dashboard.js -export default new AnalyticsHelper(window.analyticsConfig); +// Keep the shared object and method signatures for upstream compatibility. +export default new AnalyticsHelper(); +/* eslint-enable class-methods-use-this */ diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/specs/helper.spec.js b/app/javascript/dashboard/helper/AnalyticsHelper/specs/helper.spec.js index da8553779..8ae8cb906 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/specs/helper.spec.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/specs/helper.spec.js @@ -1,144 +1,51 @@ import helperObject, { AnalyticsHelper } from '../'; -vi.mock('@amplitude/analytics-browser', () => ({ - init: vi.fn(), - setUserId: vi.fn(), - identify: vi.fn(), - setGroup: vi.fn(), - groupIdentify: vi.fn(), - track: vi.fn(), - Identify: vi.fn(() => ({ - set: vi.fn(), - })), -})); - describe('helperObject', () => { - it('should return an instance of AnalyticsHelper', () => { + it('keeps the compatibility object as an AnalyticsHelper instance', () => { expect(helperObject).toBeInstanceOf(AnalyticsHelper); }); }); describe('AnalyticsHelper', () => { let analyticsHelper; + beforeEach(() => { analyticsHelper = new AnalyticsHelper({ token: 'test_token' }); }); - describe('init', () => { - it('should initialize amplitude with the correct token', async () => { - await analyticsHelper.init(); - expect(analyticsHelper.analytics).not.toBe(null); - }); + it('never initializes a provider, even when a token is supplied', async () => { + await expect(analyticsHelper.init()).resolves.toBeUndefined(); - it('should not initialize amplitude if token is not provided', async () => { - analyticsHelper = new AnalyticsHelper(); - await analyticsHelper.init(); - expect(analyticsHelper.analytics).toBe(null); - }); + expect(analyticsHelper.analytics).toBeNull(); }); - describe('identify', () => { - beforeEach(() => { - analyticsHelper.analytics = { - setUserId: vi.fn(), - identify: vi.fn(), - setGroup: vi.fn(), - groupIdentify: vi.fn(), - }; - }); - - it('should call setUserId and identify on amplitude with correct arguments', () => { + it('keeps identify as a no-op and does not retain user data', () => { + expect( analyticsHelper.identify({ id: 123, - email: 'test@example.com', + email: 'person@example.invalid', name: 'Test User', - avatar_url: 'avatar_url', - accounts: [{ id: 1, name: 'Account 1' }], + accounts: [{ id: 1, name: 'Test Account' }], account_id: 1, - }); - - expect(analyticsHelper.analytics.setUserId).toHaveBeenCalledWith( - 'user-123' - ); - expect(analyticsHelper.analytics.identify).toHaveBeenCalled(); - expect(analyticsHelper.analytics.setGroup).toHaveBeenCalledWith( - 'company', - 'account-1' - ); - expect(analyticsHelper.analytics.groupIdentify).toHaveBeenCalled(); - }); - - it('should call identify on amplitude without group', () => { - analyticsHelper.identify({ - id: 123, - email: 'test@example.com', - name: 'Test User', - avatar_url: 'avatar_url', - accounts: [{ id: 1, name: 'Account 1' }], - account_id: 5, - }); - - expect(analyticsHelper.analytics.setGroup).not.toHaveBeenCalled(); - }); - - it('should not call analytics methods if analytics is null', () => { - analyticsHelper.analytics = null; - analyticsHelper.identify({}); - expect(analyticsHelper.analytics).toBe(null); - }); + }) + ).toBeUndefined(); + expect(analyticsHelper.user).toEqual({}); }); - describe('track', () => { - beforeEach(() => { - analyticsHelper.analytics = { track: vi.fn() }; - analyticsHelper.user = { id: 123 }; - }); - - it('should call track on amplitude with correct arguments', () => { - analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' }); - expect(analyticsHelper.analytics.track).toHaveBeenCalledWith( - 'Test Event', - { prop1: 'value1', prop2: 'value2' } - ); - }); - - it('should call track on amplitude with default properties', () => { - analyticsHelper.track('Test Event'); - expect(analyticsHelper.analytics.track).toHaveBeenCalledWith( - 'Test Event', - {} - ); - }); - - it('should not call track on amplitude if analytics is not initialized', () => { - analyticsHelper.analytics = null; - analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' }); - expect(analyticsHelper.analytics).toBe(null); - }); + it('keeps track as a no-op and does not retain event data', () => { + expect( + analyticsHelper.track('Test Event', { + email: 'person@example.invalid', + account_id: 1, + }) + ).toBeUndefined(); + expect(analyticsHelper.analytics).toBeNull(); }); - describe('page', () => { - beforeEach(() => { - analyticsHelper.analytics = { track: vi.fn() }; - }); - - it('should call the track method for pageview with the correct arguments', () => { - const pageName = 'home'; - const properties = { - path: '/test', - name: 'home', - }; - analyticsHelper.page(pageName, properties); - expect(analyticsHelper.analytics.track).toHaveBeenCalledWith( - '$pageview', - { pageName: 'home', path: '/test', name: 'home' } - ); - }); - - it('should not call analytics.track if analytics is null', () => { - analyticsHelper.analytics = null; - analyticsHelper.page('home'); - expect(analyticsHelper.analytics).toBe(null); - }); + it('keeps page as a no-op and does not retain page data', () => { + expect( + analyticsHelper.page('home', { path: '/dashboard' }) + ).toBeUndefined(); + expect(analyticsHelper.analytics).toBeNull(); }); }); diff --git a/app/views/layouts/vueapp.html.erb b/app/views/layouts/vueapp.html.erb index 146f1ebda..0447b6231 100644 --- a/app/views/layouts/vueapp.html.erb +++ b/app/views/layouts/vueapp.html.erb @@ -64,13 +64,6 @@ } window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_FRONTEND_DSN', '') || ENV.fetch('SENTRY_DSN', '') %>' - <% if @global_config['CLOUD_ANALYTICS_TOKEN'].present? %> - - <% end %> <%= vite_client_tag %> <%= vite_javascript_tag @application_pack %> diff --git a/config/installation_config.yml b/config/installation_config.yml index b93e6ed91..f382cedb8 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -289,11 +289,6 @@ - name: DEPLOYMENT_ENV value: self-hosted description: 'The deployment environment of the installation, to differentiate between Chatwoot cloud and self-hosted' -- name: CLOUD_ANALYTICS_TOKEN - value: - display_title: 'Analytics Token' - description: 'The Amplitude analytics API key for Chatwoot cloud' - type: secret - name: CLEARBIT_API_KEY value: display_title: 'Clearbit API Key' diff --git a/package.json b/package.json index 63bb11d4c..22687f037 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ } ], "dependencies": { - "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 272a6ed7d..2b1147af3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,6 @@ importers: .: dependencies: - '@amplitude/analytics-browser': - specifier: ^2.11.10 - version: 2.33.1 '@breezystack/lamejs': specifier: ^1.2.7 version: 1.2.7 @@ -376,30 +373,6 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@amplitude/analytics-browser@2.33.1': - resolution: {integrity: sha512-93wZjuAFJ7QdyptF82i1pezm5jKuBWITHI++XshDgpks1RstJvJ9n11Ak8MnE4L2BGQ93XDN2aVEHfmQkt0/Pw==} - - '@amplitude/analytics-connector@1.6.4': - resolution: {integrity: sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==} - - '@amplitude/analytics-core@2.35.0': - resolution: {integrity: sha512-7RmHYELXCGu8yuO9D6lEXiqkMtiC5sePNhCWmwuP30dneDYHtH06gaYvAFH/YqOFuE6enwEEJfFYtcaPhyiqtA==} - - '@amplitude/plugin-autocapture-browser@1.18.3': - resolution: {integrity: sha512-njYque5t1QCEEe5V8Ls4yVVklTM6V7OXxBk6pqznN/hj/Pc4X8Wjy898pZ2VtbnvpagBKKzGb5B6Syl8OXiicw==} - - '@amplitude/plugin-network-capture-browser@1.7.3': - resolution: {integrity: sha512-zfWgAN7g6AigJAsgrGmlgVwydOHH6XvweBoxhU+qEvRydboiIVCDLSxuXczUsBG7kYVLWRdBK1DYoE5J7lqTGA==} - - '@amplitude/plugin-page-url-enrichment-browser@0.5.9': - resolution: {integrity: sha512-TqdELx4WrdRutCjHUFUzum/f/UjhbdTZw0UKkYFAj5gwAKDjaPEjL4waRvINOTaVLsne1A6ck4KEMfC8AKByFw==} - - '@amplitude/plugin-page-view-tracking-browser@2.6.6': - resolution: {integrity: sha512-dBcJlrdKgPzSgS3exDRRrMLqhIaOjwlIy7o8sEMn1PpMawERlbumSSdtfII6L4L67HYUPo4PY4Kp4acqSzaLvQ==} - - '@amplitude/plugin-web-vitals-browser@1.1.4': - resolution: {integrity: sha512-XQXI9OjTNSz2yi0lXw2VYMensDzzSkMCfvXNniTb1LgnHwBcQ1JWPcTqHLPFrvvNckeIdOT78vjs7yA+c1FyzA==} - '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -1398,9 +1371,6 @@ packages: '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} - '@types/zen-observable@0.8.3': - resolution: {integrity: sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==} - '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -4108,9 +4078,6 @@ packages: rust-result@1.0.0: resolution: {integrity: sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==} - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -4827,9 +4794,6 @@ packages: wavesurfer.js@7.8.6: resolution: {integrity: sha512-EDexkMwkkQBTWruhfWQRkTtvRggtKFTPuJX/oZ5wbIZEfyww9EBeLr2mtkxzA1S8TlWPx6adY5WyjOlNYNyHSg==} - web-vitals@5.1.0: - resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} - webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -4998,12 +4962,6 @@ packages: resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} engines: {node: '>=12.20'} - zen-observable-ts@1.1.0: - resolution: {integrity: sha512-1h4zlLSqI2cRLPJUHJFL8bCWHhkpuXkF+dbGkRaWjgDIG26DmzyshUMrdV/rL3UnR+mhaX4fRq8LPouq0MYYIA==} - - zen-observable@0.8.15: - resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} - snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} @@ -5014,50 +4972,6 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@amplitude/analytics-browser@2.33.1': - dependencies: - '@amplitude/analytics-core': 2.35.0 - '@amplitude/plugin-autocapture-browser': 1.18.3 - '@amplitude/plugin-network-capture-browser': 1.7.3 - '@amplitude/plugin-page-url-enrichment-browser': 0.5.9 - '@amplitude/plugin-page-view-tracking-browser': 2.6.6 - '@amplitude/plugin-web-vitals-browser': 1.1.4 - tslib: 2.8.1 - - '@amplitude/analytics-connector@1.6.4': {} - - '@amplitude/analytics-core@2.35.0': - dependencies: - '@amplitude/analytics-connector': 1.6.4 - tslib: 2.8.1 - zen-observable-ts: 1.1.0 - - '@amplitude/plugin-autocapture-browser@1.18.3': - dependencies: - '@amplitude/analytics-core': 2.35.0 - rxjs: 7.8.2 - tslib: 2.8.1 - - '@amplitude/plugin-network-capture-browser@1.7.3': - dependencies: - '@amplitude/analytics-core': 2.35.0 - tslib: 2.8.1 - - '@amplitude/plugin-page-url-enrichment-browser@0.5.9': - dependencies: - '@amplitude/analytics-core': 2.35.0 - tslib: 2.8.1 - - '@amplitude/plugin-page-view-tracking-browser@2.6.6': - dependencies: - '@amplitude/analytics-core': 2.35.0 - tslib: 2.8.1 - - '@amplitude/plugin-web-vitals-browser@1.1.4': - dependencies: - '@amplitude/analytics-core': 2.35.0 - tslib: 2.8.1 - web-vitals: 5.1.0 '@ampproject/remapping@2.3.0': dependencies: @@ -6049,7 +5963,6 @@ snapshots: '@types/web-bluetooth@0.0.20': {} - '@types/zen-observable@0.8.3': {} '@ungap/structured-clone@1.2.0': {} @@ -9169,10 +9082,6 @@ snapshots: dependencies: individual: 2.0.0 - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - sade@1.8.1: dependencies: mri: 1.2.0 @@ -9975,7 +9884,6 @@ snapshots: wavesurfer.js@7.8.6: {} - web-vitals@5.1.0: {} webidl-conversions@7.0.0: {} @@ -10132,10 +10040,3 @@ snapshots: yocto-queue@0.1.0: {} yocto-queue@1.1.1: {} - - zen-observable-ts@1.1.0: - dependencies: - '@types/zen-observable': 0.8.3 - zen-observable: 0.8.15 - - zen-observable@0.8.15: {} diff --git a/spec/controllers/dashboard_controller_spec.rb b/spec/controllers/dashboard_controller_spec.rb index e022e22c7..dec75fc46 100644 --- a/spec/controllers/dashboard_controller_spec.rb +++ b/spec/controllers/dashboard_controller_spec.rb @@ -6,6 +6,18 @@ describe '/app/login', type: :request do get '/app/login' expect(response).to have_http_status(:success) end + it 'does not serialize the cloud analytics token' do + allow(GlobalConfig).to receive(:get).and_return( + { 'CLOUD_ANALYTICS_TOKEN' => 'analytics-test-token' } + ) + + get '/app/login' + + expect(response).to have_http_status(:success) + expect(response.body).not_to include('analyticsConfig') + expect(response.body).not_to include('analytics-test-token') + end + end context 'with DEFAULT_LOCALE' do