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