Browse Source

feat: add telemetry tracking (#6210)

pull/6217/head
Daniel Starns 1 year ago
committed by GitHub
parent
commit
d0dbac7414
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      docs/.vuepress/config.js
  2. 34
      docs/guides/Telemetry.md
  3. 2
      packages/core/package.json
  4. 7
      packages/core/src/editor/config/config.ts
  5. 7
      packages/core/src/editor/model/Editor.ts
  6. 46
      packages/core/src/editor/view/EditorView.ts
  7. 4
      packages/core/src/index.ts
  8. 3
      packages/core/src/utils/host-name.ts
  9. 158
      packages/core/test/specs/editor/telemetry.ts
  10. 7
      packages/core/webpack.config.js

1
docs/.vuepress/config.js

@ -139,6 +139,7 @@ module.exports = {
['/guides/Symbols', 'Symbols'],
['/guides/Replace-Rich-Text-Editor', 'Replace Rich Text Editor'],
['/guides/Custom-CSS-parser', 'Use Custom CSS Parser'],
['/guides/Telemetry', 'GrapesJS Telemetry'],
],
},
],

34
docs/guides/Telemetry.md

@ -0,0 +1,34 @@
---
title: GrapesJS Telemetry
---
# GrapesJS Telemetry
We collect and use data to improve GrapesJS. This page explains what data we collect and how we use it.
## What data we collect
We collect the following data:
- **domain**: The domain of the website where GrapesJS is used.
- **version**: The version of GrapesJS used.
- **timestamp**: The time when the editor is loaded.
## How we use data
We use data to:
- **Improve GrapesJS**: We use data to improve GrapesJS. For example, we use data to identify bugs and fix them.
- **Analyze usage**: We use data to analyze how GrapesJS is used. For example, we use data to understand which features are used most often.
- **Provide support**: We use data to provide support to users. For example, we use data to understand how users interact with GrapesJS.
## How to opt-out
You can opt-out of data collection by setting the `telemetry` option to `false` when initializing GrapesJS:
```js
const editor = grapesjs.init({
// ...
telemetry: false,
});
```

2
packages/core/package.json

@ -74,7 +74,7 @@
"build:css": "sass src/styles/scss/main.scss dist/css/grapes.min.css --no-source-map --style=compressed --load-path=node_modules",
"ts:build": "node node_modules/grapesjs-cli/dist/cli.js build --dts='only' --patch=false",
"ts:check": "tsc --noEmit --esModuleInterop dist/index.d.ts",
"start": "run-p start:*",
"start": "cross-env NODE_ENV=development run-p start:*",
"start:js": "node node_modules/grapesjs-cli/dist/cli.js serve",
"start:css": "npm run build:css -- --watch",
"test": "jest --forceExit",

7
packages/core/src/editor/config/config.ts

@ -428,6 +428,12 @@ export interface EditorConfig {
*/
colorPicker?: ColorPickerOptions;
pStylePrefix?: string;
/**
* Telemetry options
* Default: true
*/
telemetry?: boolean;
}
export type EditorConfigKeys = keyof EditorConfig;
@ -506,6 +512,7 @@ const config: () => EditorConfig = () => ({
textViewCode: 'Code',
keepUnusedStyles: false,
customUI: false,
telemetry: true,
});
export default config;

7
packages/core/src/editor/model/Editor.ts

@ -44,6 +44,7 @@ import ComponentWrapper from '../../dom_components/model/ComponentWrapper';
import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot';
import DataSourceManager from '../../data_sources';
import { ComponentsEvents } from '../../dom_components/types';
import { InitEditorConfig } from '../..';
Backbone.$ = $;
@ -113,7 +114,7 @@ export default class EditorModel extends Model {
__skip = false;
defaultRunning = false;
destroyed = false;
_config: EditorConfig;
_config: InitEditorConfig;
_storageTimeout?: ReturnType<typeof setTimeout>;
attrsOrig: any;
timedInterval?: ReturnType<typeof setTimeout>;
@ -307,6 +308,10 @@ export default class EditorModel extends Model {
return this._config;
}
get version() {
return this.config.grapesjs?.version || '';
}
/**
* Get configurations
* @param {string} [prop] Property name

46
packages/core/src/editor/view/EditorView.ts

@ -1,4 +1,5 @@
import { View, $ } from '../../common';
import { getHostName } from '../../utils/host-name';
import { appendStyles } from '../../utils/mixins';
import EditorModel from '../model/Editor';
@ -11,6 +12,13 @@ export default class EditorView extends View<EditorModel> {
Panels.active();
Panels.disableButtons();
UndoManager.clear();
if (model.getConfig().telemetry) {
this.sendTelemetryData().catch(() => {
// Telemetry data silent fail
});
}
setTimeout(() => {
model.trigger('load', model.Editor);
model.clearDirtyCount();
@ -47,4 +55,42 @@ export default class EditorView extends View<EditorModel> {
return this;
}
private async sendTelemetryData() {
const domain = getHostName();
if (domain === 'localhost' || domain.includes('localhost')) {
// Don't send telemetry data for localhost
return;
}
const sessionKeyPrefix = 'gjs_telemetry_sent_';
const { version } = this.model;
const sessionKey = `${sessionKeyPrefix}${version}`;
if (sessionStorage.getItem(sessionKey)) {
// Telemetry already sent for version this session
return;
}
const url = 'https://app.grapesjs.com';
const response = await fetch(`${url}/api/gjs/telemetry/collect`, {
method: 'POST',
body: JSON.stringify({ domain, version }),
});
if (!response.ok) {
throw new Error(`Failed to send telemetry data ${await response.text()}`);
}
sessionStorage.setItem(sessionKey, 'true');
Object.keys(sessionStorage).forEach((key) => {
if (key.startsWith(sessionKeyPrefix) && key !== sessionKey) {
sessionStorage.removeItem(key);
}
});
this.trigger('telemetry:sent');
}
}

4
packages/core/src/index.ts

@ -5,7 +5,7 @@ import PluginManager, { Plugin, getPlugin, logPluginWarn } from './plugin_manage
import $ from './utils/cash-dom';
import polyfills from './utils/polyfills';
interface InitEditorConfig extends EditorConfig {
export interface InitEditorConfig extends EditorConfig {
grapesjs?: typeof grapesjs;
}
@ -36,7 +36,7 @@ export const grapesjs = {
usePlugin,
// @ts-ignore Will be replaced on build
version: __GJS_VERSION__,
version: __GJS_VERSION__ as string,
/**
* Initialize the editor with passed options

3
packages/core/src/utils/host-name.ts

@ -0,0 +1,3 @@
export function getHostName() {
return window.location.hostname;
}

158
packages/core/test/specs/editor/telemetry.ts

@ -0,0 +1,158 @@
import grapesjs from '../../../src';
import { EditorConfig } from '../../../src/editor/config/config';
import { fixJsDom, fixJsDomIframe, waitEditorEvent } from '../../common';
import * as hostUtil from '../../../src/utils/host-name';
jest.mock('../../../src/utils/host-name');
describe('Editor telemetry', () => {
const version = '1.0.0';
let fixture: HTMLElement;
let editorName = '';
let htmlString = '';
let config: Partial<EditorConfig>;
let cssString = '';
let documentEl = '';
let originalFetch: typeof fetch;
let fetchMock: jest.Mock;
const initTestEditor = (config: Partial<EditorConfig>) => {
grapesjs.version = version;
const editor = grapesjs.init({
...config,
plugins: [fixJsDom, ...(config.plugins || [])],
});
fixJsDomIframe(editor.getModel().shallow);
return editor;
};
beforeAll(() => {
jest.spyOn(hostUtil, 'getHostName').mockReturnValue('example.com');
editorName = 'editor-fixture';
});
beforeEach(() => {
const initHtml = '<div class="test1"></div><div class="test2"></div>';
htmlString = `<body>${initHtml}</body>`;
cssString = '.test2{color:red}.test3{color:blue}';
documentEl = '<style>' + cssString + '</style>' + initHtml;
config = {
container: '#' + editorName,
storageManager: {
autoload: false,
autosave: false,
type: '',
},
};
document.body.innerHTML = `<div id="fixtures"><div id="${editorName}"></div></div>`;
fixture = document.body.querySelector(`#${editorName}`)!;
originalFetch = global.fetch;
fetchMock = jest.fn(() => Promise.resolve({ ok: true }));
global.fetch = fetchMock;
const sessionStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
};
Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock });
Object.defineProperty(window, 'location', {
value: {
hostname: 'example.com',
},
});
console.log = jest.fn();
console.error = jest.fn();
});
afterEach(() => {
global.fetch = originalFetch;
jest.resetAllMocks();
});
test('Telemetry is sent when enabled', async () => {
const editor = initTestEditor({
...config,
telemetry: true,
});
await waitEditorEvent(editor, 'load');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain('/api/gjs/telemetry/collect');
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({
domain: expect.any(String),
version: expect.any(String),
});
});
test('Telemetry is not sent when disabled', async () => {
const editor = initTestEditor({
...config,
telemetry: false,
});
await waitEditorEvent(editor, 'load');
expect(fetchMock).not.toHaveBeenCalled();
});
test('Telemetry is not sent twice in the same session', async () => {
window.sessionStorage.getItem = jest.fn(() => 'true');
const editor = initTestEditor({
...config,
telemetry: true,
});
await waitEditorEvent(editor, 'load');
expect(fetchMock).not.toHaveBeenCalled();
});
test('Telemetry handles fetch errors gracefully', async () => {
fetchMock.mockRejectedValueOnce(new Error('Network error'));
const editor = initTestEditor({
...config,
telemetry: true,
});
await waitEditorEvent(editor, 'load');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(console.log).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
});
test('Telemetry cleans up old version keys', async () => {
const sessionStorageMock = {
getItem: jest.fn(() => null),
setItem: jest.fn(),
removeItem: jest.fn(),
'gjs_telemetry_sent_0.9.0': 'true',
'gjs_telemetry_sent_0.9.1': 'true',
other_key: 'true',
};
Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock });
Object.defineProperty(sessionStorageMock, 'length', { value: 3 });
fetchMock.mockResolvedValueOnce({ ok: true });
const editor = initTestEditor({
...config,
telemetry: true,
});
await waitEditorEvent(editor, 'load');
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(sessionStorageMock.setItem).toHaveBeenCalledWith(`gjs_telemetry_sent_${version}`, 'true');
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('gjs_telemetry_sent_0.9.0');
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('gjs_telemetry_sent_0.9.1');
expect(sessionStorageMock.removeItem).not.toHaveBeenCalledWith('other_key');
}, 10000);
});

7
packages/core/webpack.config.js

@ -41,6 +41,11 @@ module.exports = ({ config, pkg, webpack }) => {
underscore: `${rootDir}/node_modules/underscore`,
},
},
plugins: [new webpack.DefinePlugin({ __GJS_VERSION__: `'${pkg.version}'` }), ...config.plugins],
plugins: [
new webpack.DefinePlugin({
__GJS_VERSION__: `'${pkg.version}'`,
}),
...config.plugins,
],
};
};

Loading…
Cancel
Save