From d0dbac7414690da13010bf32a8632209da23bd63 Mon Sep 17 00:00:00 2001 From: Daniel Starns Date: Tue, 15 Oct 2024 08:08:20 -0700 Subject: [PATCH] feat: add telemetry tracking (#6210) --- docs/.vuepress/config.js | 1 + docs/guides/Telemetry.md | 34 ++++ packages/core/package.json | 2 +- packages/core/src/editor/config/config.ts | 7 + packages/core/src/editor/model/Editor.ts | 7 +- packages/core/src/editor/view/EditorView.ts | 46 ++++++ packages/core/src/index.ts | 4 +- packages/core/src/utils/host-name.ts | 3 + packages/core/test/specs/editor/telemetry.ts | 158 +++++++++++++++++++ packages/core/webpack.config.js | 7 +- 10 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 docs/guides/Telemetry.md create mode 100644 packages/core/src/utils/host-name.ts create mode 100644 packages/core/test/specs/editor/telemetry.ts diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 5f39c82b59..7aaef1e64e 100644 --- a/docs/.vuepress/config.js +++ b/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'], ], }, ], diff --git a/docs/guides/Telemetry.md b/docs/guides/Telemetry.md new file mode 100644 index 0000000000..39823c5e98 --- /dev/null +++ b/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, +}); +``` diff --git a/packages/core/package.json b/packages/core/package.json index df39cd0f94..ef22ee94cf 100644 --- a/packages/core/package.json +++ b/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", diff --git a/packages/core/src/editor/config/config.ts b/packages/core/src/editor/config/config.ts index 3b55d45715..27c6d837c8 100644 --- a/packages/core/src/editor/config/config.ts +++ b/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; diff --git a/packages/core/src/editor/model/Editor.ts b/packages/core/src/editor/model/Editor.ts index 33390b2ab3..e64418548b 100644 --- a/packages/core/src/editor/model/Editor.ts +++ b/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; attrsOrig: any; timedInterval?: ReturnType; @@ -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 diff --git a/packages/core/src/editor/view/EditorView.ts b/packages/core/src/editor/view/EditorView.ts index 02618976d6..18d20306b1 100644 --- a/packages/core/src/editor/view/EditorView.ts +++ b/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 { 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 { 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'); + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 40c710e474..62688da966 100644 --- a/packages/core/src/index.ts +++ b/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 diff --git a/packages/core/src/utils/host-name.ts b/packages/core/src/utils/host-name.ts new file mode 100644 index 0000000000..5d46feb7c1 --- /dev/null +++ b/packages/core/src/utils/host-name.ts @@ -0,0 +1,3 @@ +export function getHostName() { + return window.location.hostname; +} diff --git a/packages/core/test/specs/editor/telemetry.ts b/packages/core/test/specs/editor/telemetry.ts new file mode 100644 index 0000000000..66cf3ade88 --- /dev/null +++ b/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; + let cssString = ''; + let documentEl = ''; + + let originalFetch: typeof fetch; + let fetchMock: jest.Mock; + + const initTestEditor = (config: Partial) => { + 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 = '
'; + htmlString = `${initHtml}`; + cssString = '.test2{color:red}.test3{color:blue}'; + documentEl = '' + initHtml; + config = { + container: '#' + editorName, + storageManager: { + autoload: false, + autosave: false, + type: '', + }, + }; + document.body.innerHTML = `
`; + 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); +}); diff --git a/packages/core/webpack.config.js b/packages/core/webpack.config.js index 33577c8252..7dee91595a 100644 --- a/packages/core/webpack.config.js +++ b/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, + ], }; };