[verified] privacy: remove product analytics

This commit is contained in:
Kunthawat Greethong
2026-08-15 23:27:09 +07:00
parent 5619cc38d5
commit a96b9773c7
8 changed files with 53 additions and 312 deletions

View File

@@ -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

View File

@@ -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 */

View File

@@ -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();
});
});

View File

@@ -64,13 +64,6 @@
}
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_FRONTEND_DSN', '') || ENV.fetch('SENTRY_DSN', '') %>'
</script>
<% if @global_config['CLOUD_ANALYTICS_TOKEN'].present? %>
<script>
window.analyticsConfig = {
token: '<%= @global_config['CLOUD_ANALYTICS_TOKEN'] %>',
}
</script>
<% end %>
<%= vite_client_tag %>
<%= vite_javascript_tag @application_pack %>
</head>

View File

@@ -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'

View File

@@ -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",

99
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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