From 10f5f0cb377b96777d191ecf9948eff7be9cfd6f Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Tue, 6 Jun 2023 13:08:50 -0400 Subject: [PATCH] (core) Add optional telemetry to grist-core Summary: Adds support for optional telemetry to grist-core. A new environment variable, GRIST_TELEMETRY_LEVEL, controls the level of telemetry collected. Test Plan: Server and unit tests. Reviewers: paulfitz Reviewed By: paulfitz Subscribers: dsagal, anaisconce Differential Revision: https://phab.getgrist.com/D3880 --- README.md | 1 + app/client/lib/helpScout.ts | 4 +- app/client/lib/telemetry.ts | 16 +- app/client/models/errors.ts | 2 +- app/client/ui/AccountWidget.ts | 32 +- app/client/ui/AppHeader.ts | 39 +- app/client/ui/OpenVideoTour.ts | 2 +- app/client/ui/WelcomeCoachingCall.ts | 162 ++++ app/client/ui/WelcomeCoachingCallStub.ts | 9 - app/common/Telemetry.ts | 934 ++++++++++++++++++++- app/common/gristUrls.ts | 14 + app/common/orgNameUtils.ts | 2 +- app/gen-server/entity/Document.ts | 14 +- app/gen-server/lib/HomeDBManager.ts | 8 +- app/gen-server/lib/Housekeeper.ts | 44 +- app/server/generateInitialDocSql.ts | 3 +- app/server/lib/ActiveDoc.ts | 71 +- app/server/lib/AppEndpoint.ts | 33 +- app/server/lib/Authorizer.ts | 13 +- app/server/lib/Client.ts | 5 +- app/server/lib/DocApi.ts | 6 +- app/server/lib/FlexServer.ts | 65 +- app/server/lib/GristServer.ts | 21 +- app/server/lib/ICreate.ts | 18 +- app/server/lib/ProcessMonitor.ts | 26 +- app/server/lib/Telemetry.ts | 210 +++++ app/server/lib/Triggers.ts | 2 +- app/server/lib/sendAppPage.ts | 40 +- app/server/lib/serverUtils.ts | 13 +- app/server/mergedServerMain.ts | 4 +- stubs/app/client/ui/BillingButtons.ts | 16 - stubs/app/client/ui/WelcomeCoachingCall.ts | 1 - stubs/app/server/lib/TelemetryManager.ts | 11 - stubs/app/server/lib/create.ts | 5 + test/common/Telemetry.ts | 215 +++++ test/nbrowser/testServer.ts | 6 +- test/server/lib/Authorizer.ts | 2 +- test/server/lib/Telemetry.ts | 309 +++++++ 38 files changed, 2177 insertions(+), 201 deletions(-) create mode 100644 app/client/ui/WelcomeCoachingCall.ts delete mode 100644 app/client/ui/WelcomeCoachingCallStub.ts create mode 100644 app/server/lib/Telemetry.ts delete mode 100644 stubs/app/client/ui/BillingButtons.ts delete mode 100644 stubs/app/client/ui/WelcomeCoachingCall.ts delete mode 100644 stubs/app/server/lib/TelemetryManager.ts create mode 100644 test/common/Telemetry.ts create mode 100644 test/server/lib/Telemetry.ts diff --git a/README.md b/README.md index e4f81ee0..fdc6b3a8 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,7 @@ GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org GRIST_HELP_CENTER | set the help center link ref GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default) GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way. +GRIST_TELEMETRY_LEVEL | the telemetry level. Can be set to: `off` (default), `limited`, or `full`. GRIST_THROTTLE_CPU | if set, CPU throttling is enabled GRIST_USER_ROOT | an extra path to look for plugins in. GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,multiSite,multiAccounts,sendToDrive,tutorials`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled. diff --git a/app/client/lib/helpScout.ts b/app/client/lib/helpScout.ts index 231894f0..137b4dfe 100644 --- a/app/client/lib/helpScout.ts +++ b/app/client/lib/helpScout.ts @@ -197,11 +197,11 @@ function _beaconOpen(userObj: IUserObj|null, options: IBeaconOpenOptions) { Beacon('once', 'open', () => logTelemetryEvent('beaconOpen')); Beacon('on', 'article-viewed', (article) => logTelemetryEvent('beaconArticleViewed', { - articleId: article!.id, + full: {articleId: article!.id}, })); Beacon('on', 'email-sent', () => logTelemetryEvent('beaconEmailSent')); Beacon('on', 'search', (search) => logTelemetryEvent('beaconSearch', { - searchQuery: search!.query, + full: {searchQuery: search!.query}, })); } diff --git a/app/client/lib/telemetry.ts b/app/client/lib/telemetry.ts index 9e1a8ad2..1e3dee63 100644 --- a/app/client/lib/telemetry.ts +++ b/app/client/lib/telemetry.ts @@ -1,14 +1,20 @@ import {logError} from 'app/client/models/errors'; -import {TelemetryEventName} from 'app/common/Telemetry'; -import {fetchFromHome, pageHasHome} from 'app/common/urlUtils'; +import {Level, TelemetryContracts, TelemetryEvent, TelemetryMetadataByLevel} from 'app/common/Telemetry'; +import {fetchFromHome, getGristConfig, pageHasHome} from 'app/common/urlUtils'; -export function logTelemetryEvent(name: TelemetryEventName, metadata?: Record) { +export function logTelemetryEvent(event: TelemetryEvent, metadata?: TelemetryMetadataByLevel) { if (!pageHasHome()) { return; } + const {telemetry} = getGristConfig(); + if (!telemetry) { return; } + + const {telemetryLevel} = telemetry; + if (Level[telemetryLevel] < TelemetryContracts[event].minimumTelemetryLevel) { return; } + fetchFromHome('/api/telemetry', { method: 'POST', body: JSON.stringify({ - name, + event, metadata, }), credentials: 'include', @@ -17,7 +23,7 @@ export function logTelemetryEvent(name: TelemetryEventName, metadata?: Record { - console.warn(`Failed to log telemetry event ${name}`, e); + console.warn(`Failed to log telemetry event ${event}`, e); logError(e); }); } diff --git a/app/client/models/errors.ts b/app/client/models/errors.ts index c884b04b..219f047a 100644 --- a/app/client/models/errors.ts +++ b/app/client/models/errors.ts @@ -215,6 +215,6 @@ export function logError(error: Error|string) { }).catch(e => { // There ... isn't much we can do about this. // tslint:disable-next-line:no-console - console.warn('Failed to log event', event); + console.warn('Failed to log event', e); }); } diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 413f4d66..ef45f649 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -1,7 +1,6 @@ import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/client/models/gristUrlState'; -import {buildUserMenuBillingItem} from 'app/client/ui/BillingButtons'; import {manageTeamUsers} from 'app/client/ui/OpenUserManager'; import {createUserImage} from 'app/client/ui/UserImage'; import * as viewport from 'app/client/ui/viewport'; @@ -16,6 +15,7 @@ import {Disposable, dom, DomElementArg, styled} from 'grainjs'; import {cssMenuItem} from 'popweasel'; import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher'; import {makeT} from 'app/client/lib/localization'; +import {getGristConfig} from 'app/common/urlUtils'; const t = makeT('AccountWidget'); @@ -98,7 +98,8 @@ export class AccountWidget extends Disposable { // Don't show on doc pages, or for personal orgs. null), - buildUserMenuBillingItem(this._appModel), + this._maybeBuildBillingPageMenuItem(), + this._maybeBuildActivationPageMenuItem(), mobileModeToggle, @@ -141,6 +142,33 @@ export class AccountWidget extends Disposable { } this._appModel.topAppModel.initialize(); } + + private _maybeBuildBillingPageMenuItem() { + const {deploymentType} = getGristConfig(); + if (deploymentType !== 'saas') { return null; } + + const {currentValidUser, currentOrg, isTeamSite} = this._appModel; + const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount && + (currentOrg.billingAccount.isManager || currentValidUser?.isSupport)); + + return isTeamSite ? + // For links, disabling with just a class is hard; easier to just not make it a link. + // TODO weasel menus should support disabling menuItemLink. + (isBillingManager ? + menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') : + menuItem(() => null, 'Billing Account', dom.cls('disabled', true)) + ) : + menuItem(() => this._appModel.showUpgradeModal(), 'Upgrade Plan'); + } + + private _maybeBuildActivationPageMenuItem() { + const {activation, deploymentType} = getGristConfig(); + if (deploymentType !== 'enterprise' || !activation?.isManager) { + return null; + } + + return menuItemLink('Activation', urlState().setLinkUrl({activation: 'activation'})); + } } const cssAccountWidget = styled('div', ` diff --git a/app/client/ui/AppHeader.ts b/app/client/ui/AppHeader.ts index d8dadfee..c5a17ae5 100644 --- a/app/client/ui/AppHeader.ts +++ b/app/client/ui/AppHeader.ts @@ -1,5 +1,4 @@ import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState'; -import {buildAppMenuBillingItem} from 'app/client/ui/BillingButtons'; import {getTheme} from 'app/client/ui/CustomThemes'; import {cssLeftPane} from 'app/client/ui/PagePanels'; import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars'; @@ -14,6 +13,7 @@ import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager'; import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher'; import {BindableValue, Disposable, dom, DomContents, styled} from 'grainjs'; import {makeT} from 'app/client/lib/localization'; +import {getGristConfig} from 'app/common/urlUtils'; const t = makeT('AppHeader'); @@ -71,7 +71,8 @@ export class AppHeader extends Disposable { // Don't show on doc pages, or for personal orgs. null), - buildAppMenuBillingItem(this._appModel, testId('orgmenu-billing')), + this._maybeBuildBillingPageMenuItem(), + this._maybeBuildActivationPageMenuItem(), maybeAddSiteSwitcherSection(this._appModel), ], { placement: 'bottom-start' }), @@ -88,6 +89,40 @@ export class AppHeader extends Disposable { return {href: getWelcomeHomeUrl()}; } } + + private _maybeBuildBillingPageMenuItem() { + const {deploymentType} = getGristConfig(); + if (deploymentType !== 'saas') { return null; } + + const {currentOrg} = this._appModel; + const isBillingManager = this._appModel.isBillingManager() || this._appModel.isSupport(); + return currentOrg && !currentOrg.owner ? + // For links, disabling with just a class is hard; easier to just not make it a link. + // TODO weasel menus should support disabling menuItemLink. + (isBillingManager + ? menuItemLink( + urlState().setLinkUrl({billing: 'billing'}), + 'Billing Account', + testId('orgmenu-billing'), + ) + : menuItem( + () => null, + 'Billing Account', + dom.cls('disabled', true), + testId('orgmenu-billing'), + ) + ) : + null; + } + + private _maybeBuildActivationPageMenuItem() { + const {activation, deploymentType} = getGristConfig(); + if (deploymentType !== 'enterprise' || !activation?.isManager) { + return null; + } + + return menuItemLink('Activation', urlState().setLinkUrl({activation: 'activation'})); + } } export function productPill(org: Organization|null, options: {large?: boolean} = {}): DomContents { diff --git a/app/client/ui/OpenVideoTour.ts b/app/client/ui/OpenVideoTour.ts index fa96d8ff..193b3bef 100644 --- a/app/client/ui/OpenVideoTour.ts +++ b/app/client/ui/OpenVideoTour.ts @@ -37,7 +37,7 @@ const VIDEO_TOUR_YOUTUBE_EMBED_ID = 'qnr2Pfnxdlc'; if (youtubePlayer.isLoading()) { return; } logTelemetryEvent('watchedVideoTour', { - watchTimeSeconds: Math.floor(youtubePlayer.getCurrentTime()), + limited: {watchTimeSeconds: Math.floor(youtubePlayer.getCurrentTime())}, }); }); diff --git a/app/client/ui/WelcomeCoachingCall.ts b/app/client/ui/WelcomeCoachingCall.ts new file mode 100644 index 00000000..1621a81e --- /dev/null +++ b/app/client/ui/WelcomeCoachingCall.ts @@ -0,0 +1,162 @@ +import {AppModel} from 'app/client/models/AppModel'; +import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; +import {testId, theme, vars} from 'app/client/ui2018/cssVars'; +import {cardPopup, cssPopupBody, cssPopupButtons, cssPopupCloseButton, + cssPopupTitle} from 'app/client/ui2018/popups'; +import {icon} from 'app/client/ui2018/icons'; +import {getGristConfig} from 'app/common/urlUtils'; +import {dom, styled} from 'grainjs'; + +const FREE_COACHING_CALL_URL = 'https://calendly.com/grist-team/grist-free-coaching-call'; + +export function shouldShowWelcomeCoachingCall(appModel: AppModel) { + const {deploymentType} = getGristConfig(); + if (deploymentType !== 'saas') { return false; } + + const {behavioralPromptsManager, dismissedWelcomePopups} = appModel; + + // Defer showing coaching call until Add New tip is dismissed. + const hasSeenAddNewTip = behavioralPromptsManager.hasSeenTip('addNew'); + const shouldShowTips = behavioralPromptsManager.shouldShowTips(); + if (!hasSeenAddNewTip && shouldShowTips) { return false; } + + const popup = dismissedWelcomePopups.get().find(p => p.id === 'coachingCall'); + return ( + // Only show if the user is an owner. + appModel.isOwner() && ( + // And preferences for the popup haven't been saved before. + popup === undefined || + // Or the popup has been shown before, and it's time to shown it again. + popup.nextAppearanceAt !== null && popup.nextAppearanceAt <= Date.now() + ) + ); +} + +/** + * Shows a popup with an offer for a free coaching call. + */ +export function showWelcomeCoachingCall(triggerElement: Element, appModel: AppModel) { + const {dismissedWelcomePopups} = appModel; + + cardPopup(triggerElement, (ctl) => { + const dismissPopup = (scheduleNextAppearance?: boolean) => { + const dismissedPopups = dismissedWelcomePopups.get(); + const newDismissedPopups = [...dismissedPopups]; + const coachingPopup = newDismissedPopups.find(p => p.id === 'coachingCall'); + if (!coachingPopup) { + newDismissedPopups.push({ + id: 'coachingCall', + lastDismissedAt: Date.now(), + timesDismissed: 1, + nextAppearanceAt: scheduleNextAppearance + ? new Date().setDate(new Date().getDate() + 7) + : null, + }); + } else { + Object.assign(coachingPopup, { + lastDismissedAt: Date.now(), + timesDismissed: coachingPopup.timesDismissed + 1, + nextAppearanceAt: scheduleNextAppearance && coachingPopup.timesDismissed + 1 <= 1 + ? new Date().setDate(new Date().getDate() + 7) + : null, + }); + } + dismissedWelcomePopups.set(newDismissedPopups); + ctl.close(); + }; + + // TODO: i18n + return [ + cssPopup.cls(''), + cssPopupHeader( + cssLogoAndName( + cssLogo(), + cssName('Grist'), + ), + cssPopupCloseButton( + cssCloseIcon('CrossBig'), + dom.on('click', () => dismissPopup(true)), + testId('popup-close-button'), + ), + ), + cssPopupTitle('Free Coaching Call', testId('popup-title')), + cssPopupBody( + cssBody( + dom('div', + 'Schedule your ', cssBoldText('free coaching call'), ' with a member of our team.' + ), + dom('div', + "On the call, we'll take the time to understand your needs and " + + 'tailor the call to you. We can show you the Grist basics, or start ' + + 'working with your data right away to build the dashboards you need.' + ), + ), + testId('popup-body'), + ), + cssPopupButtons( + bigPrimaryButtonLink( + 'Schedule Call', + dom.on('click', () => dismissPopup(false)), + { + href: FREE_COACHING_CALL_URL, + target: '_blank', + }, + testId('popup-primary-button'), + ), + bigBasicButton( + 'Maybe Later', + dom.on('click', () => dismissPopup(true)), + testId('popup-basic-button'), + ), + ), + testId('coaching-call'), + ]; + }); +} + +const cssBody = styled('div', ` + display: flex; + flex-direction: column; + row-gap: 16px; +`); + +const cssBoldText = styled('span', ` + font-weight: 600; +`); + +const cssCloseIcon = styled(icon, ` + padding: 12px; +`); + +const cssName = styled('div', ` + color: ${theme.popupCloseButtonFg}; + font-size: ${vars.largeFontSize}; + font-weight: 600; +`); + +const cssLogo = styled('div', ` + flex: none; + height: 32px; + width: 32px; + background-image: var(--icon-GristLogo); + background-size: ${vars.logoSize}; + background-repeat: no-repeat; + background-position: center; +`); + +const cssLogoAndName = styled('div', ` + display: flex; + align-items: center; + gap: 4px; +`); + +const cssPopup = styled('div', ` + display: flex; + flex-direction: column; +`); + +const cssPopupHeader = styled('div', ` + display: flex; + justify-content: space-between; + margin-bottom: 16px; +`); diff --git a/app/client/ui/WelcomeCoachingCallStub.ts b/app/client/ui/WelcomeCoachingCallStub.ts deleted file mode 100644 index 79638caa..00000000 --- a/app/client/ui/WelcomeCoachingCallStub.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {AppModel} from 'app/client/models/AppModel'; - -export function shouldShowWelcomeCoachingCall(_app: AppModel) { - return false; -} - -export function showWelcomeCoachingCall(_triggerElement: Element, _app: AppModel) { - -} diff --git a/app/common/Telemetry.ts b/app/common/Telemetry.ts index 0a7da18c..ba6f529f 100644 --- a/app/common/Telemetry.ts +++ b/app/common/Telemetry.ts @@ -1,6 +1,796 @@ -export const TelemetryTemplateSignupCookieName = 'gr_template_signup_trk'; +import {StringUnion} from 'app/common/StringUnion'; +import pickBy = require('lodash/pickBy'); -export const TelemetryEventNames = [ +/** + * Telemetry levels, in increasing order of data collected. + */ +export enum Level { + off = 0, + limited = 1, + full = 2, +} + +/** + * A set of contracts that all telemetry events must follow prior to being + * logged. + * + * Currently, this includes meeting minimum telemetry levels for events + * and their metadata, and passing in the correct data type for the value of + * each metadata property. + * + * The `minimumTelemetryLevel` defined at the event level will also be applied + * to all metadata properties of an event, and can be overridden at the metadata + * level. + */ +export const TelemetryContracts: TelemetryContracts = { + /** + * Triggered when an HTTP request with an API key is made. + */ + apiUsage: { + minimumTelemetryLevel: Level.full, + metadataContracts: { + /** + * The HTTP request method (e.g. GET, POST, PUT). + */ + method: { + dataType: 'string', + }, + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + }, + /** + * The User-Agent HTTP request header. + */ + userAgent: { + dataType: 'string', + }, + }, + }, + /** + * Triggered when HelpScout Beacon is opened. + */ + beaconOpen: { + minimumTelemetryLevel: Level.full, + metadataContracts: { + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + }, + /** + * A random, session-based identifier for the user that triggered this event. + */ + altSessionId: { + dataType: 'string', + }, + }, + }, + /** + * Triggered when an article is opened in HelpScout Beacon. + */ + beaconArticleViewed: { + minimumTelemetryLevel: Level.full, + metadataContracts: { + /** + * The id of the article. + */ + articleId: { + dataType: 'string', + }, + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + }, + /** + * A random, session-based identifier for the user that triggered this event. + */ + altSessionId: { + dataType: 'string', + }, + }, + }, + /** + * Triggered when an email is sent in HelpScout Beacon. + */ + beaconEmailSent: { + minimumTelemetryLevel: Level.full, + metadataContracts: { + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + }, + /** + * A random, session-based identifier for the user that triggered this event. + */ + altSessionId: { + dataType: 'string', + }, + }, + }, + /** + * Triggered when a search is made in HelpScout Beacon. + */ + beaconSearch: { + minimumTelemetryLevel: Level.full, + metadataContracts: { + /** + * The search query. + */ + searchQuery: { + dataType: 'string', + }, + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + }, + /** + * A random, session-based identifier for the user that triggered this event. + */ + altSessionId: { + dataType: 'string', + }, + }, + }, + /** + * Triggered when a document is forked. + */ + documentForked: { + minimumTelemetryLevel: Level.limited, + metadataContracts: { + /** + * A hash of the doc id. + */ + docIdDigest: { + dataType: 'string', + }, + /** + * The id of the site containing the forked document. + */ + siteId: { + dataType: 'number', + minimumTelemetryLevel: Level.full, + }, + /** + * The type of the site. + */ + siteType: { + dataType: 'string', + minimumTelemetryLevel: Level.full, + }, + /** + * A random, session-based identifier for the user that triggered this event. + */ + altSessionId: { + dataType: 'string', + minimumTelemetryLevel: Level.full, + }, + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + minimumTelemetryLevel: Level.full, + }, + /** + * A hash of the fork id. + */ + forkIdDigest: { + dataType: 'string', + }, + /** + * A hash of the full id of the fork, including the trunk id and fork id. + */ + forkDocIdDigest: { + dataType: 'string', + }, + /** + * A hash of the trunk id. + */ + trunkIdDigest: { + dataType: 'string', + }, + /** + * Whether the trunk is a template. + */ + isTemplate: { + dataType: 'boolean', + }, + /** + * Timestamp of the last update to the trunk document. + */ + lastActivity: { + dataType: 'date', + }, + }, + }, + /** + * Triggered when a public document or template is opened. + */ + documentOpened: { + minimumTelemetryLevel: Level.limited, + metadataContracts: { + /** + * A hash of the doc id. + */ + docIdDigest: { + dataType: 'string', + }, + /** + * The site id. + */ + siteId: { + dataType: 'number', + minimumTelemetryLevel: Level.full, + }, + /** + * The site type. + */ + siteType: { + dataType: 'string', + minimumTelemetryLevel: Level.full, + }, + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + minimumTelemetryLevel: Level.full, + }, + /** + * A random, session-based identifier for the user that triggered this event. + */ + altSessionId: { + dataType: 'string', + minimumTelemetryLevel: Level.full, + }, + /** + * The document access level of the user that triggered this event. + */ + access: { + dataType: 'boolean', + }, + /** + * Whether the document is public. + */ + isPublic: { + dataType: 'boolean', + }, + /** + * Whether a snapshot was opened. + */ + isSnapshot: { + dataType: 'boolean', + }, + /** + * Whether the document is a template. + */ + isTemplate: { + dataType: 'boolean', + }, + /** + * Timestamp of when the document was last updated. + */ + lastUpdated: { + dataType: 'date', + }, + }, + }, + /** + * Triggered on doc open and close, as well as hourly while a document is open. + */ + documentUsage: { + minimumTelemetryLevel: Level.limited, + metadataContracts: { + /** + * A hash of the doc id. + */ + docIdDigest: { + dataType: 'string', + }, + /** + * The site id. + */ + siteId: { + dataType: 'number', + minimumTelemetryLevel: Level.full, + }, + /** + * The site type. + */ + siteType: { + dataType: 'string', + minimumTelemetryLevel: Level.full, + }, + /** + * A random, session-based identifier for the user that triggered this event. + */ + altSessionId: { + dataType: 'string', + minimumTelemetryLevel: Level.full, + }, + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + minimumTelemetryLevel: Level.full, + }, + /** + * What caused this event to trigger. + * + * May be either "docOpen", "interval", or "docClose". + */ + triggeredBy: { + dataType: 'string', + }, + /** + * Whether the document is public. + */ + isPublic: { + dataType: 'boolean', + }, + /** + * The number of rows in the document. + */ + rowCount: { + dataType: 'number', + }, + /** + * The total size of all data in the document, excluding attachments. + */ + dataSizeBytes: { + dataType: 'number', + }, + /** + * The total size of all attachments in the document. + */ + attachmentsSize: { + dataType: 'number', + }, + /** + * The number of access rules in the document. + */ + numAccessRules: { + dataType: 'number', + }, + /** + * The number of user attributes in the document. + */ + numUserAttributes: { + dataType: 'number', + }, + /** + * The number of attachments in the document. + */ + numAttachments: { + dataType: 'number', + }, + /** + * A list of unique file extensions compiled from all of the document's attachments. + */ + attachmentTypes: { + dataType: 'string[]', + }, + /** + * The number of charts in the document. + */ + numCharts: { + dataType: 'number', + }, + /** + * A list of chart types of every chart in the document. + */ + chartTypes: { + dataType: 'string[]', + }, + /** + * The number of linked charts in the document. + */ + numLinkedCharts: { + dataType: 'number', + }, + /** + * The number of linked widgets in the document. + */ + numLinkedWidgets: { + dataType: 'number', + }, + /** + * The number of columns in the document. + */ + numColumns: { + dataType: 'number', + }, + /** + * The number of columns with conditional formatting in the document. + */ + numColumnsWithConditionalFormatting: { + dataType: 'number', + }, + /** + * The number of formula columns in the document. + */ + numFormulaColumns: { + dataType: 'number', + }, + /** + * The number of trigger formula columns in the document. + */ + numTriggerFormulaColumns: { + dataType: 'number', + }, + /** + * The number of summary formula columns in the document. + */ + numSummaryFormulaColumns: { + dataType: 'number', + }, + /** + * The number of fields with conditional formatting in the document. + */ + numFieldsWithConditionalFormatting: { + dataType: 'number', + }, + /** + * The number of tables in the document. + */ + numTables: { + dataType: 'number', + }, + /** + * The number of on-demand tables in the document. + */ + numOnDemandTables: { + dataType: 'number', + }, + /** + * The number of tables with conditional formatting in the document. + */ + numTablesWithConditionalFormatting: { + dataType: 'number', + }, + /** + * The number of summary tables in the document. + */ + numSummaryTables: { + dataType: 'number', + }, + /** + * The number of custom widgets in the document. + */ + numCustomWidgets: { + dataType: 'number', + }, + /** + * A list of plugin ids for every custom widget in the document. + * + * The ids of widgets not created by Grist Labs are replaced with "externalId". + */ + customWidgetIds: { + dataType: 'string[]', + }, + }, + }, + /** + * Triggered every 5 seconds. + */ + processMonitor: { + minimumTelemetryLevel: Level.full, + metadataContracts: { + /** Size of JS heap in use, in MiB. */ + heapUsedMB: { + dataType: 'number', + }, + /** Total heap size, in MiB, allocated for JS by V8. */ + heapTotalMB: { + dataType: 'number', + }, + /** Fraction (typically between 0 and 1) of CPU usage. Includes all threads, so may exceed 1. */ + cpuAverage: { + dataType: 'number', + }, + /** Interval (in milliseconds) over which `cpuAverage` is reported. */ + intervalMs: { + dataType: 'number', + }, + }, + }, + /** + * Triggered when sending webhooks. + */ + sendingWebhooks: { + minimumTelemetryLevel: Level.limited, + metadataContracts: { + /** + * The number of events in the batch of webhooks being sent. + */ + numEvents: { + dataType: 'number', + }, + /** + * A hash of the doc id. + */ + docIdDigest: { + dataType: 'string', + }, + /** + * The site id. + */ + siteId: { + dataType: 'number', + minimumTelemetryLevel: Level.full, + }, + /** + * The site type. + */ + siteType: { + dataType: 'string', + minimumTelemetryLevel: Level.full, + }, + /** + * A random, session-based identifier for the user that triggered this event. + */ + altSessionId: { + dataType: 'string', + minimumTelemetryLevel: Level.full, + }, + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + minimumTelemetryLevel: Level.full, + }, + }, + }, + /** + * Triggered after a user successfully verifies their account during sign-up. + * + * Not triggered in grist-core. + */ + signupVerified: { + minimumTelemetryLevel: Level.full, + metadataContracts: { + /** + * Whether the user viewed any templates before signing up. + */ + isAnonymousTemplateSignup: { + dataType: 'boolean', + }, + /** + * The doc id of the template the user last viewed before signing up, if any. + */ + templateId: { + dataType: 'string', + }, + }, + }, + /** + * Triggered daily. + */ + siteMembership: { + minimumTelemetryLevel: Level.limited, + metadataContracts: { + /** + * The site id. + */ + siteId: { + dataType: 'number', + }, + /** + * The site type. + */ + siteType: { + dataType: 'string', + }, + /** + * The number of users with an owner role in this site. + */ + numOwners: { + dataType: 'number', + }, + /** + * The number of users with an editor role in this site. + */ + numEditors: { + dataType: 'number', + }, + /** + * The number of users with a viewer role in this site. + */ + numViewers: { + dataType: 'number', + }, + }, + }, + /** + * Triggered daily. + */ + siteUsage: { + minimumTelemetryLevel: Level.limited, + metadataContracts: { + /** + * The site id. + */ + siteId: { + dataType: 'number', + }, + /** + * The site type. + */ + siteType: { + dataType: 'string', + }, + /** + * Whether the site's subscription is in good standing. + */ + inGoodStanding: { + dataType: 'boolean', + }, + /** + * The Stripe Plan id associated with this site. + */ + stripePlanId: { + dataType: 'string', + minimumTelemetryLevel: Level.full, + }, + /** + * The number of docs in this site. + */ + numDocs: { + dataType: 'number', + }, + /** + * The number of workspaces in this site. + */ + numWorkspaces: { + dataType: 'number', + }, + /** + * The number of site members. + */ + numMembers: { + dataType: 'number', + }, + /** + * A timestamp of the most recent update made to a site document. + */ + lastActivity: { + dataType: 'date', + }, + }, + }, + /** + * Triggered on changes to tutorial progress. + */ + tutorialProgressChanged: { + minimumTelemetryLevel: Level.full, + metadataContracts: { + /** + * A hash of the tutorial fork id. + */ + tutorialForkIdDigest: { + dataType: 'string', + }, + /** + * A hash of the tutorial trunk id. + */ + tutorialTrunkIdDigest: { + dataType: 'string', + }, + /** + * The 0-based index of the last tutorial slide the user had open. + */ + lastSlideIndex: { + dataType: 'number', + }, + /** + * The total number of slides in the tutorial. + */ + numSlides: { + dataType: 'number', + }, + /** + * Percentage of tutorial completion. + */ + percentComplete: { + dataType: 'number', + }, + }, + }, + /** + * Triggered when a tutorial is restarted. + */ + tutorialRestarted: { + minimumTelemetryLevel: Level.full, + metadataContracts: { + /** + * A hash of the tutorial fork id. + */ + tutorialForkIdDigest: { + dataType: 'string', + }, + /** + * A hash of the tutorial trunk id. + */ + tutorialTrunkIdDigest: { + dataType: 'string', + }, + /** + * A hash of the doc id. + */ + docIdDigest: { + dataType: 'string', + }, + /** + * The site id. + */ + siteId: { + dataType: 'number', + }, + /** + * The site type. + */ + siteType: { + dataType: 'string', + }, + /** + * A random, session-based identifier for the user that triggered this event. + */ + altSessionId: { + dataType: 'string', + }, + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + }, + }, + }, + /** + * Triggered when the video tour is closed. + */ + watchedVideoTour: { + minimumTelemetryLevel: Level.limited, + metadataContracts: { + /** + * The number of seconds elapsed in the video player. + */ + watchTimeSeconds: { + dataType: 'number', + }, + /** + * The id of the user that triggered this event. + */ + userId: { + dataType: 'number', + minimumTelemetryLevel: Level.full, + }, + /** + * A random, session-based identifier for the user that triggered this event. + */ + altSessionId: { + dataType: 'string', + minimumTelemetryLevel: Level.full, + }, + }, + }, +}; + +type TelemetryContracts = Record; + +export const TelemetryEvents = StringUnion( 'apiUsage', 'beaconOpen', 'beaconArticleViewed', @@ -17,6 +807,142 @@ export const TelemetryEventNames = [ 'tutorialProgressChanged', 'tutorialRestarted', 'watchedVideoTour', -] as const; +); +export type TelemetryEvent = typeof TelemetryEvents.type; + +interface TelemetryEventContract { + minimumTelemetryLevel: Level; + metadataContracts?: Record; +} + +interface MetadataContract { + dataType: 'boolean' | 'number' | 'string' | 'string[]' | 'date'; + minimumTelemetryLevel?: Level; +} + +export type TelemetryMetadataByLevel = Partial>; + +export type EnabledTelemetryLevel = Exclude; + +export const TelemetryLevels = StringUnion('off', 'limited', 'full'); +export type TelemetryLevel = typeof TelemetryLevels.type; + +export type TelemetryMetadata = Record; + +/** + * The name of a cookie that's set whenever a template is opened. + * + * The cookie remembers the last template that was opened, which is then read during + * sign-up to track which templates were viewed before sign-up. + */ +export const TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME = 'gr_template_signup_trk'; + +// A set of metadata keys that are always allowed when logging. +const ALLOWED_METADATA_KEYS = new Set(['eventSource', 'installationId']); + +/** + * Returns a function that accepts a telemetry event and metadata, and performs various + * checks on it based on a set of contracts and the `telemetryLevel`. + * + * The function throws if any checks fail. + */ +export function buildTelemetryEventChecker(telemetryLevel: TelemetryLevel) { + const currentTelemetryLevel = Level[telemetryLevel]; + + return (event: TelemetryEvent, metadata?: TelemetryMetadata) => { + const eventContract = TelemetryContracts[event]; + if (!eventContract) { + throw new Error(`Unknown telemetry event: ${event}`); + } + + const eventMinimumTelemetryLevel = eventContract.minimumTelemetryLevel; + if (currentTelemetryLevel < eventMinimumTelemetryLevel) { + throw new Error( + `Telemetry event ${event} requires a minimum telemetry level of ${eventMinimumTelemetryLevel} ` + + `but the current level is ${currentTelemetryLevel}` + ); + } + + for (const [key, value] of Object.entries(metadata ?? {})) { + if (ALLOWED_METADATA_KEYS.has(key)) { continue; } + + const metadataContract = eventContract.metadataContracts?.[key]; + if (!metadataContract) { + throw new Error(`Unknown metadata for telemetry event ${event}: ${key}`); + } + + const metadataMinimumTelemetryLevel = metadataContract.minimumTelemetryLevel; + if (metadataMinimumTelemetryLevel && currentTelemetryLevel < metadataMinimumTelemetryLevel) { + throw new Error( + `Telemetry metadata ${key} of event ${event} requires a minimum telemetry level of ` + + `${metadataMinimumTelemetryLevel} but the current level is ${currentTelemetryLevel}` + ); + } + + const {dataType} = metadataContract; + if (dataType.endsWith('[]')) { + if (!Array.isArray(value)) { + throw new Error( + `Telemetry metadata ${key} of event ${event} expected a value of type array ` + + `but received a value of type ${typeof value}` + ); + } + + const elementDataType = dataType.slice(0, -2); + if (value.some(element => typeof element !== elementDataType)) { + throw new Error( + `Telemetry metadata ${key} of event ${event} expected a value of type ${elementDataType}[] ` + + `but received a value of type ${typeof value}[]` + ); + } + } else if (dataType === 'date') { + if (!(value instanceof Date) && typeof value !== 'string') { + throw new Error( + `Telemetry metadata ${key} of event ${event} expected a value of type Date or string ` + + `but received a value of type ${typeof value}` + ); + } + } else if (dataType !== typeof value) { + throw new Error( + `Telemetry metadata ${key} of event ${event} expected a value of type ${dataType} ` + + `but received a value of type ${typeof value}` + ); + } + } + }; +} + +export type TelemetryEventChecker = (event: TelemetryEvent, metadata?: TelemetryMetadata) => void; + +/** + * Returns a new, filtered metadata object. + * + * Metadata in groups that don't meet `telemetryLevel` are removed from the + * returned object, and the returned object is flattened. + * + * Returns undefined if `metadata` is undefined. + */ +export function filterMetadata( + metadata: TelemetryMetadataByLevel | undefined, + telemetryLevel: TelemetryLevel +): TelemetryMetadata | undefined { + if (!metadata) { return; } + + let filteredMetadata = {}; + for (const level of ['limited', 'full'] as const) { + if (Level[telemetryLevel] < Level[level]) { break; } + + filteredMetadata = {...filteredMetadata, ...metadata[level]}; + } + + filteredMetadata = removeNullishKeys(filteredMetadata); + + return removeNullishKeys(filteredMetadata); +} -export type TelemetryEventName = typeof TelemetryEventNames[number]; +/** + * Returns a copy of `object` with all null and undefined keys removed. + */ +export function removeNullishKeys(object: Record) { + return pickBy(object, value => value !== null && value !== undefined); +} diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 503371fe..7a642818 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -4,6 +4,7 @@ import {EngineCode} from 'app/common/DocumentSettings'; import {encodeQueryParams, isAffirmative} from 'app/common/gutil'; import {LocalPlugin} from 'app/common/plugin'; import {StringUnion} from 'app/common/StringUnion'; +import {TelemetryLevel} from 'app/common/Telemetry'; import {UIRowId} from 'app/common/UIRowId'; import {getGristConfig} from 'app/common/urlUtils'; import {Document} from 'app/common/UserAPI'; @@ -627,6 +628,12 @@ export interface GristLoadConfig { // Current user locale, read from the user options; userLocale?: string; + + // Telemetry config. + telemetry?: TelemetryConfig; + + // The Grist deployment type (e.g. core, enterprise). + deploymentType?: GristDeploymentType; } export const Features = StringUnion( @@ -648,6 +655,13 @@ export function getPageTitleSuffix(config?: GristLoadConfig) { return config?.pageTitleSuffix ?? " - Grist"; } +export interface TelemetryConfig { + telemetryLevel: TelemetryLevel; +} + +export const GristDeploymentTypes = StringUnion('saas', 'core', 'enterprise', 'electron', 'static'); +export type GristDeploymentType = typeof GristDeploymentTypes.type; + /** * For a packaged version of Grist that requires activation, this * summarizes the current state. Not applicable to grist-core. diff --git a/app/common/orgNameUtils.ts b/app/common/orgNameUtils.ts index 3b682575..3ee9070d 100644 --- a/app/common/orgNameUtils.ts +++ b/app/common/orgNameUtils.ts @@ -16,7 +16,7 @@ const BLACKLISTED_SUBDOMAINS = new Set([ 'docs', 'api', 'static', 'ftp', 'imap', 'pop', 'smtp', 'mail', 'git', 'blog', 'wiki', 'support', 'kb', 'help', 'admin', 'store', 'dev', 'beta', - 'community', 'try', 'wpx', + 'community', 'try', 'wpx', 'telemetry', // a few random tech brands 'google', 'apple', 'microsoft', 'ms', 'facebook', 'fb', 'twitter', 'youtube', 'yt', diff --git a/app/gen-server/entity/Document.ts b/app/gen-server/entity/Document.ts index 2cbda145..859864c0 100644 --- a/app/gen-server/entity/Document.ts +++ b/app/gen-server/entity/Document.ts @@ -164,12 +164,14 @@ export class Document extends Resource { const percentComplete = lastSlideIndex !== undefined && numSlides !== undefined ? Math.floor((lastSlideIndex / numSlides) * 100) : undefined; - dbManager?.emit('tutorialProgressChange', { - tutorialForkIdDigest: hashId(this.id), - tutorialTrunkIdDigest: this.trunkId ? hashId(this.trunkId) : undefined, - lastSlideIndex, - numSlides, - percentComplete, + dbManager?.emit('tutorialProgressChanged', { + full: { + tutorialForkIdDigest: hashId(this.id), + tutorialTrunkIdDigest: this.trunkId ? hashId(this.trunkId) : undefined, + lastSlideIndex, + numSlides, + percentComplete, + }, }); } } diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 5172cf98..27c28e74 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -89,13 +89,13 @@ export const NotifierEvents = StringUnion( export type NotifierEvent = typeof NotifierEvents.type; -export const TelemetryEvents = StringUnion( - 'tutorialProgressChange', +export const HomeDBTelemetryEvents = StringUnion( + 'tutorialProgressChanged', ); -export type TelemetryEvent = typeof TelemetryEvents.type; +export type HomeDBTelemetryEvent = typeof HomeDBTelemetryEvents.type; -export type Event = NotifierEvent | TelemetryEvent; +export type Event = NotifierEvent | HomeDBTelemetryEvent; // Nominal email address of a user who can view anything (for thumbnails). export const PREVIEWER_EMAIL = 'thumbnail@getgrist.com'; diff --git a/app/gen-server/lib/Housekeeper.ts b/app/gen-server/lib/Housekeeper.ts index 2796d413..1a0d3efb 100644 --- a/app/gen-server/lib/Housekeeper.ts +++ b/app/gen-server/lib/Housekeeper.ts @@ -37,6 +37,7 @@ export class Housekeeper { private _deleteTrashinterval?: NodeJS.Timeout; private _logMetricsInterval?: NodeJS.Timeout; private _electionKey?: string; + private _telemetry = this._server.getTelemetry(); public constructor(private _dbManager: HomeDBManager, private _server: GristServer, private _permitStore: IPermitStore, private _electionStore: IElectionStore) { @@ -174,30 +175,37 @@ export class Housekeeper { */ public async logMetrics() { await this._dbManager.connection.transaction('READ UNCOMMITTED', async (manager) => { - const telemetryManager = this._server.getTelemetryManager(); const usageSummaries = await this._getOrgUsageSummaries(manager); for (const summary of usageSummaries) { - telemetryManager?.logEvent('siteUsage', { - siteId: summary.site_id, - siteType: summary.site_type, - inGoodStanding: Boolean(summary.in_good_standing), - stripePlanId: summary.stripe_plan_id, - numDocs: Number(summary.num_docs), - numWorkspaces: Number(summary.num_workspaces), - numMembers: Number(summary.num_members), - lastActivity: summary.last_activity, - }); + this._telemetry.logEvent('siteUsage', { + limited: { + siteId: summary.site_id, + siteType: summary.site_type, + inGoodStanding: Boolean(summary.in_good_standing), + numDocs: Number(summary.num_docs), + numWorkspaces: Number(summary.num_workspaces), + numMembers: Number(summary.num_members), + lastActivity: summary.last_activity, + }, + full: { + stripePlanId: summary.stripe_plan_id, + }, + }) + .catch(e => log.error('failed to log telemetry event siteUsage', e)); } const membershipSummaries = await this._getOrgMembershipSummaries(manager); for (const summary of membershipSummaries) { - telemetryManager?.logEvent('siteMembership', { - siteId: summary.site_id, - siteType: summary.site_type, - numOwners: Number(summary.num_owners), - numEditors: Number(summary.num_editors), - numViewers: Number(summary.num_viewers), - }); + this._telemetry.logEvent('siteMembership', { + limited: { + siteId: summary.site_id, + siteType: summary.site_type, + numOwners: Number(summary.num_owners), + numEditors: Number(summary.num_editors), + numViewers: Number(summary.num_viewers), + }, + }) + .catch(e => log.error('failed to log telemetry event siteMembership', e)); } }); } diff --git a/app/server/generateInitialDocSql.ts b/app/server/generateInitialDocSql.ts index d38fd025..51ea2781 100644 --- a/app/server/generateInitialDocSql.ts +++ b/app/server/generateInitialDocSql.ts @@ -3,6 +3,7 @@ import { create } from 'app/server/lib/create'; import { DocManager } from 'app/server/lib/DocManager'; import { makeExceptionalDocSession } from 'app/server/lib/DocSession'; import { DocStorageManager } from 'app/server/lib/DocStorageManager'; +import { createDummyTelemetry } from 'app/server/lib/GristServer'; import { PluginManager } from 'app/server/lib/PluginManager'; import * as childProcess from 'child_process'; @@ -33,7 +34,7 @@ export async function main(baseName: string) { } const docManager = new DocManager(storageManager, pluginManager, null as any, { create, - getTelemetryManager: () => undefined, + getTelemetry() { return createDummyTelemetry(); }, } as any); const activeDoc = new ActiveDoc(docManager, baseName); const session = makeExceptionalDocSession('nascent'); diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 536d5fb2..dcbe7259 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -74,7 +74,7 @@ import {Interval} from 'app/common/Interval'; import * as roles from 'app/common/roles'; import {schema, SCHEMA_VERSION} from 'app/common/schema'; import {MetaRowRecord, SingleCell} from 'app/common/TableData'; -import {TelemetryEventName} from 'app/common/Telemetry'; +import {TelemetryEvent, TelemetryMetadataByLevel} from 'app/common/Telemetry'; import {UIRowId} from 'app/common/UIRowId'; import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; import {Document as APIDocument, DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI'; @@ -1395,11 +1395,13 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc { // TODO: Need a more precise way to identify a template. (This org now also has tutorials.) const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial'; this.logTelemetryEvent(docSession, 'documentForked', { - forkIdDigest: hashId(forkIds.forkId), - forkDocIdDigest: hashId(forkIds.docId), - trunkIdDigest: doc.trunkId ? hashId(doc.trunkId) : undefined, - isTemplate, - lastActivity: doc.updatedAt, + limited: { + forkIdDigest: hashId(forkIds.forkId), + forkDocIdDigest: hashId(forkIds.docId), + trunkIdDigest: doc.trunkId ? hashId(doc.trunkId) : undefined, + isTemplate, + lastActivity: doc.updatedAt, + }, }); } finally { await permitStore.removePermit(permitKey); @@ -1789,13 +1791,14 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc { public logTelemetryEvent( docSession: OptDocSession | null, - eventName: TelemetryEventName, - metadata?: Record + event: TelemetryEvent, + metadata?: TelemetryMetadataByLevel ) { - this._docManager.gristServer.getTelemetryManager()?.logEvent(eventName, { - ...this._getTelemetryMeta(docSession), - ...metadata, - }); + this._docManager.gristServer.getTelemetry().logEvent(event, merge( + this._getTelemetryMeta(docSession), + metadata, + )) + .catch(e => this._log.error(docSession, `failed to log telemetry event ${event}`, e)); } /** @@ -2332,18 +2335,20 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc { private _logDocMetrics(docSession: OptDocSession, triggeredBy: 'docOpen' | 'interval'| 'docClose') { this.logTelemetryEvent(docSession, 'documentUsage', { - triggeredBy, - isPublic: ((this._doc as unknown) as APIDocument)?.public ?? false, - rowCount: this._docUsage?.rowCount?.total, - dataSizeBytes: this._docUsage?.dataSizeBytes, - attachmentsSize: this._docUsage?.attachmentsSizeBytes, - ...this._getAccessRuleMetrics(), - ...this._getAttachmentMetrics(), - ...this._getChartMetrics(), - ...this._getWidgetMetrics(), - ...this._getColumnMetrics(), - ...this._getTableMetrics(), - ...this._getCustomWidgetMetrics(), + limited: { + triggeredBy, + isPublic: ((this._doc as unknown) as APIDocument)?.public ?? false, + rowCount: this._docUsage?.rowCount?.total, + dataSizeBytes: this._docUsage?.dataSizeBytes, + attachmentsSize: this._docUsage?.attachmentsSizeBytes, + ...this._getAccessRuleMetrics(), + ...this._getAttachmentMetrics(), + ...this._getChartMetrics(), + ...this._getWidgetMetrics(), + ...this._getColumnMetrics(), + ...this._getTableMetrics(), + ...this._getCustomWidgetMetrics(), + }, }); } @@ -2365,10 +2370,10 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc { // Exclude the leading ".", if any. .map(r => r.fileExt?.trim()?.slice(1)) .filter(ext => Boolean(ext)); - + const uniqueAttachmentTypes = [...new Set(attachmentTypes ?? [])]; return { numAttachments, - attachmentTypes, + attachmentTypes: uniqueAttachmentTypes, }; } @@ -2528,15 +2533,19 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc { this._logDocMetrics(docSession, 'docOpen'); } - private _getTelemetryMeta(docSession: OptDocSession|null) { + private _getTelemetryMeta(docSession: OptDocSession|null): TelemetryMetadataByLevel { const altSessionId = docSession ? getDocSessionAltSessionId(docSession) : undefined; return merge( docSession ? getTelemetryMetaFromDocSession(docSession) : {}, - altSessionId ? {altSessionId} : undefined, + altSessionId ? {altSessionId} : {}, { - docIdDigest: hashId(this._docName), - siteId: this._doc?.workspace.org.id, - siteType: this._product?.name, + limited: { + docIdDigest: hashId(this._docName), + }, + full: { + siteId: this._doc?.workspace.org.id, + siteType: this._product?.name, + }, }, ); } diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index adf14bde..1f009e65 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -11,7 +11,7 @@ import {getSlugIfNeeded, parseSubdomainStrictly, parseUrlId} from 'app/common/gr import {removeTrailingSlash} from 'app/common/gutil'; import {hashId} from 'app/common/hashingUtils'; import {LocalPlugin} from "app/common/plugin"; -import {TelemetryTemplateSignupCookieName} from 'app/common/Telemetry'; +import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry'; import {Document as APIDocument} from 'app/common/UserAPI'; import {TEMPLATES_ORG_DOMAIN} from 'app/gen-server/ApiServer'; import {Document} from "app/gen-server/entity/Document"; @@ -308,18 +308,23 @@ export function attachAppEndpoint(options: AttachOptions): void { // TODO: Need a more precise way to identify a template. (This org now also has tutorials.) const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial'; if (isPublic || isTemplate) { - gristServer.getTelemetryManager()?.logEvent('documentOpened', { - docIdDigest: hashId(docId), - siteId: doc.workspace.org.id, - siteType: doc.workspace.org.billingAccount.product.name, - userId: mreq.userId, - altSessionId: mreq.altSessionId, - access: doc.access, - isPublic, - isSnapshot, - isTemplate, - lastUpdated: doc.updatedAt, - }); + gristServer.getTelemetry().logEvent('documentOpened', { + limited: { + docIdDigest: hashId(docId), + access: doc.access, + isPublic, + isSnapshot, + isTemplate, + lastUpdated: doc.updatedAt, + }, + full: { + siteId: doc.workspace.org.id, + siteType: doc.workspace.org.billingAccount.product.name, + userId: mreq.userId, + altSessionId: mreq.altSessionId, + }, + }) + .catch(e => log.error('failed to log telemetry event documentOpened', e)); } if (isTemplate) { @@ -330,7 +335,7 @@ export function attachAppEndpoint(options: AttachOptions): void { isAnonymous: isAnonymousUser(mreq), templateId: docId, }; - res.cookie(TelemetryTemplateSignupCookieName, JSON.stringify(value), { + res.cookie(TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME, JSON.stringify(value), { maxAge: 1000 * 60 * 60, httpOnly: true, path: '/', diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 7a745f98..33cb657c 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -396,11 +396,14 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer }; log.rawDebug(`Auth[${meta.method}]: ${meta.host} ${meta.path}`, meta); if (hasApiKey) { - options.gristServer.getTelemetryManager()?.logEvent('apiUsage', { - method: mreq.method, - userId: mreq.userId, - userAgent: req.headers['user-agent'], - }); + options.gristServer.getTelemetry().logEvent('apiUsage', { + full: { + method: mreq.method, + userId: mreq.userId, + userAgent: mreq.headers['user-agent'], + }, + }) + .catch(e => log.error('failed to log telemetry event apiUsage', e)); } return next(); diff --git a/app/server/lib/Client.ts b/app/server/lib/Client.ts index 730b030d..fa7e14bc 100644 --- a/app/server/lib/Client.ts +++ b/app/server/lib/Client.ts @@ -4,6 +4,7 @@ import {delay} from 'app/common/delay'; import {CommClientConnect, CommMessage, CommResponse, CommResponseError} from 'app/common/CommTypes'; import {ErrorWithCode} from 'app/common/ErrorWithCode'; import {UserProfile} from 'app/common/LoginSessionAPI'; +import {TelemetryMetadata} from 'app/common/Telemetry'; import {ANONYMOUS_USER_EMAIL} from 'app/common/UserAPI'; import {User} from 'app/gen-server/entity/User'; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; @@ -433,8 +434,8 @@ export class Client { return meta; } - public getFullTelemetryMeta() { - const meta: Record = {}; + public getFullTelemetryMeta(): TelemetryMetadata { + const meta: TelemetryMetadata = {}; // We assume the _userId has already been cached, which will be true always (for all practical // purposes) because it's set when the Authorizer checks this client. if (this._userId) { meta.userId = this._userId; } diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index b685f5f6..f60645aa 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -916,8 +916,10 @@ export class DocWorkerApi { }); const {forkId} = parseUrlId(scope.urlId); activeDoc.logTelemetryEvent(docSession, 'tutorialRestarted', { - tutorialForkIdDigest: forkId ? hashId(forkId) : undefined, - tutorialTrunkIdDigest: hashId(tutorialTrunkId), + full: { + tutorialForkIdDigest: forkId ? hashId(forkId) : undefined, + tutorialTrunkIdDigest: hashId(tutorialTrunkId), + }, }); } } diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index b2f7ff4d..395bbadf 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1,12 +1,12 @@ import {ApiError} from 'app/common/ApiError'; import {delay} from 'app/common/delay'; import {DocCreationInfo} from 'app/common/DocListAPI'; -import {encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState, isOrgInPathOnly, - parseSubdomain, sanitizePathTail} from 'app/common/gristUrls'; +import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes, + GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain, + sanitizePathTail} from 'app/common/gristUrls'; import {getOrgUrlInfo} from 'app/common/gristUrls'; import {UserProfile} from 'app/common/LoginSessionAPI'; import {tbind} from 'app/common/tbind'; -import {TelemetryEventName, TelemetryEventNames} from 'app/common/Telemetry'; import * as version from 'app/common/version'; import {ApiServer, getOrgFromRequest} from 'app/gen-server/ApiServer'; import {Document} from "app/gen-server/entity/Document"; @@ -57,7 +57,7 @@ import {getDatabaseUrl, listenPromise} from 'app/server/lib/serverUtils'; import {Sessions} from 'app/server/lib/Sessions'; import * as shutdown from 'app/server/lib/shutdown'; import {TagChecker} from 'app/server/lib/TagChecker'; -import {TelemetryManager} from 'app/server/lib/TelemetryManager'; +import {ITelemetry} from 'app/server/lib/Telemetry'; import {startTestingHooks} from 'app/server/lib/TestingHooks'; import {getTestLoginSystem} from 'app/server/lib/TestLogin'; import {addUploadRoute} from 'app/server/lib/uploads'; @@ -117,7 +117,9 @@ export class FlexServer implements GristServer { public electronServerMethods: ElectronServerMethods; public readonly docsRoot: string; public readonly i18Instance: i18n; + private _activations: Activations; private _comm: Comm; + private _deploymentType: GristDeploymentType; private _dbManager: HomeDBManager; private _defaultBaseDomain: string|undefined; private _pluginUrl: string|undefined; @@ -130,7 +132,7 @@ export class FlexServer implements GristServer { private _sessions: Sessions; private _sessionStore: SessionStore; private _storageManager: IDocStorageManager; - private _telemetryManager: TelemetryManager|undefined; + private _telemetry: ITelemetry; private _processMonitorStop?: () => void; // Callback to stop the ProcessMonitor private _docWorkerMap: IDocWorkerMap; private _widgetRepository: IWidgetRepository; @@ -199,6 +201,11 @@ export class FlexServer implements GristServer { this.docsRoot = fse.realpathSync(docsRoot); this.info.push(['docsRoot', this.docsRoot]); + this._deploymentType = this.create.deploymentType(); + if (process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE) { + this._deploymentType = GristDeploymentTypes.check(process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE); + } + const homeUrl = process.env.APP_HOME_URL; // The "base domain" is only a thing if orgs are encoded as a subdomain. if (process.env.GRIST_ORG_IN_PATH === 'true' || process.env.GRIST_SINGLE_ORG) { @@ -328,11 +335,20 @@ export class FlexServer implements GristServer { return this._comm; } + public getDeploymentType(): GristDeploymentType { + return this._deploymentType; + } + public getHosts(): Hosts { if (!this._hosts) { throw new Error('no hosts available'); } return this._hosts; } + public getActivations(): Activations { + if (!this._activations) { throw new Error('no activations available'); } + return this._activations; + } + public getHomeDBManager(): HomeDBManager { if (!this._dbManager) { throw new Error('no home db available'); } return this._dbManager; @@ -343,8 +359,9 @@ export class FlexServer implements GristServer { return this._storageManager; } - public getTelemetryManager(): TelemetryManager|undefined { - return this._telemetryManager; + public getTelemetry(): ITelemetry { + if (!this._telemetry) { throw new Error('no telemetry available'); } + return this._telemetry; } public getWidgetRepository(): IWidgetRepository { @@ -553,8 +570,8 @@ export class FlexServer implements GristServer { // Report which database we are using, without sensitive credentials. this.info.push(['database', getDatabaseUrl(this._dbManager.connection.options, false)]); // If the installation appears to be new, give it an id and a creation date. - const activations = new Activations(this._dbManager); - await activations.current(); + this._activations = new Activations(this._dbManager); + await this._activations.current(); } public addDocWorkerMap() { @@ -689,24 +706,14 @@ export class FlexServer implements GristServer { }); } - public addTelemetryEndpoint() { - if (this._check('telemetry-endpoint', 'json', 'api-mw', 'homedb')) { return; } + public addTelemetry() { + if (this._check('telemetry', 'homedb', 'json', 'api-mw')) { return; } - this._telemetryManager = new TelemetryManager(this._dbManager); + this._telemetry = this.create.Telemetry(this._dbManager, this); + this._telemetry.addEndpoints(this.app); // Start up a monitor for memory and cpu usage. - this._processMonitorStop = ProcessMonitor.start(this._telemetryManager); - - this.app.post('/api/telemetry', async (req, resp) => { - const mreq = req as RequestWithLogin; - const name = stringParam(req.body.name, 'name', TelemetryEventNames); - this._telemetryManager?.logEvent(name as TelemetryEventName, { - userId: mreq.userId, - altSessionId: mreq.altSessionId, - ...req.body.metadata, - }); - return resp.status(200).send(); - }); + this._processMonitorStop = ProcessMonitor.start(this._telemetry); } public async close() { @@ -828,7 +835,7 @@ export class FlexServer implements GristServer { // Initialize _sendAppPage helper. this._sendAppPage = makeSendAppPage({ - server: isSingleUserMode() ? null : this, + server: this, staticDir: getAppPathTo(this.appRoot, 'static'), tag: this.tag, testLogin: allowTestLogin(), @@ -1108,7 +1115,7 @@ export class FlexServer implements GristServer { // Add document-related endpoints and related support. public async addDoc() { this._check('doc', 'start', 'tag', 'json', isSingleUserMode() ? - null : 'homedb', 'api-mw', 'map', 'telemetry-endpoint'); + null : 'homedb', 'api-mw', 'map', 'telemetry'); // add handlers for cleanup, if we are in charge of the doc manager. if (!this._docManager) { this.addCleanup(); } await this.loadConfig(); @@ -1368,7 +1375,11 @@ export class FlexServer implements GristServer { } public getGristConfig(): GristLoadConfig { - return makeGristConfig(this.getDefaultHomeUrl(), {}, this._defaultBaseDomain); + return makeGristConfig({ + homeUrl: this.getDefaultHomeUrl(), + extra: {}, + baseDomain: this._defaultBaseDomain, + }); } /** diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index 750bd03c..cdf72c72 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -1,8 +1,9 @@ -import { GristLoadConfig } from 'app/common/gristUrls'; +import { GristDeploymentType, GristLoadConfig } from 'app/common/gristUrls'; import { FullUser, UserProfile } from 'app/common/UserAPI'; import { Document } from 'app/gen-server/entity/Document'; import { Organization } from 'app/gen-server/entity/Organization'; import { Workspace } from 'app/gen-server/entity/Workspace'; +import { Activations } from 'app/gen-server/lib/Activations'; import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { IAccessTokens } from 'app/server/lib/AccessTokens'; import { RequestWithLogin } from 'app/server/lib/Authorizer'; @@ -16,7 +17,7 @@ import { IPermitStore } from 'app/server/lib/Permit'; import { ISendAppPageOptions } from 'app/server/lib/sendAppPage'; import { fromCallback } from 'app/server/lib/serverUtils'; import { Sessions } from 'app/server/lib/Sessions'; -import { TelemetryManager } from 'app/server/lib/TelemetryManager'; +import { ITelemetry } from 'app/server/lib/Telemetry'; import * as express from 'express'; import { IncomingMessage } from 'http'; @@ -40,10 +41,12 @@ export interface GristServer { getExternalPermitStore(): IPermitStore; getSessions(): Sessions; getComm(): Comm; + getDeploymentType(): GristDeploymentType; getHosts(): Hosts; + getActivations(): Activations; getHomeDBManager(): HomeDBManager; getStorageManager(): IDocStorageManager; - getTelemetryManager(): TelemetryManager|undefined; + getTelemetry(): ITelemetry; getNotifier(): INotifier; getDocTemplate(): Promise; getTag(): string; @@ -117,10 +120,12 @@ export function createDummyGristServer(): GristServer { getResourceUrl() { return Promise.resolve(''); }, getSessions() { throw new Error('no sessions'); }, getComm() { throw new Error('no comms'); }, + getDeploymentType() { return 'core'; }, getHosts() { throw new Error('no hosts'); }, + getActivations() { throw new Error('no activations'); }, getHomeDBManager() { throw new Error('no db'); }, getStorageManager() { throw new Error('no storage manager'); }, - getTelemetryManager() { return undefined; }, + getTelemetry() { return createDummyTelemetry(); }, getNotifier() { throw new Error('no notifier'); }, getDocTemplate() { throw new Error('no doc template'); }, getTag() { return 'tag'; }, @@ -128,3 +133,11 @@ export function createDummyGristServer(): GristServer { getAccessTokens() { throw new Error('no access tokens'); }, }; } + +export function createDummyTelemetry(): ITelemetry { + return { + logEvent() { return Promise.resolve(); }, + addEndpoints() { /* do nothing */ }, + getTelemetryLevel() { return 'off'; }, + }; +} diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts index 1072133f..b49942fd 100644 --- a/app/server/lib/ICreate.ts +++ b/app/server/lib/ICreate.ts @@ -1,19 +1,22 @@ +import {GristDeploymentType} from 'app/common/gristUrls'; import {getThemeBackgroundSnippet} from 'app/common/Themes'; import {Document} from 'app/gen-server/entity/Document'; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {ExternalStorage} from 'app/server/lib/ExternalStorage'; -import {GristServer} from 'app/server/lib/GristServer'; +import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer'; import {IBilling} from 'app/server/lib/IBilling'; import {INotifier} from 'app/server/lib/INotifier'; import {ISandbox, ISandboxCreationOptions} from 'app/server/lib/ISandbox'; import {IShell} from 'app/server/lib/IShell'; import {createSandbox, SpawnFn} from 'app/server/lib/NSandbox'; import {SqliteVariant} from 'app/server/lib/SqliteCommon'; +import {ITelemetry} from 'app/server/lib/Telemetry'; export interface ICreate { Billing(dbManager: HomeDBManager, gristConfig: GristServer): IBilling; Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier; + Telemetry(dbManager: HomeDBManager, gristConfig: GristServer): ITelemetry; Shell?(): IShell; // relevant to electron version of Grist only. // Create a space to store files externally, for storing either: @@ -25,6 +28,7 @@ export interface ICreate { NSandbox(options: ISandboxCreationOptions): ISandbox; + deploymentType(): GristDeploymentType; sessionSecret(): string; // Check configuration of the app early enough to show on startup. configure?(): Promise; @@ -57,19 +61,26 @@ export interface ICreateBillingOptions { create(dbManager: HomeDBManager, gristConfig: GristServer): IBilling|undefined; } +export interface ICreateTelemetryOptions { + create(dbManager: HomeDBManager, gristConfig: GristServer): ITelemetry|undefined; +} + export function makeSimpleCreator(opts: { + deploymentType: GristDeploymentType, sessionSecret?: string, storage?: ICreateStorageOptions[], billing?: ICreateBillingOptions, notifier?: ICreateNotifierOptions, + telemetry?: ICreateTelemetryOptions, sandboxFlavor?: string, shell?: IShell, getExtraHeadHtml?: () => string, getSqliteVariant?: () => SqliteVariant, getSandboxVariants?: () => Record, }): ICreate { - const {sessionSecret, storage, notifier, billing} = opts; + const {deploymentType, sessionSecret, storage, notifier, billing, telemetry} = opts; return { + deploymentType() { return deploymentType; }, Billing(dbManager, gristConfig) { return billing?.create(dbManager, gristConfig) ?? { addEndpoints() { /* do nothing */ }, @@ -93,6 +104,9 @@ export function makeSimpleCreator(opts: { } return undefined; }, + Telemetry(dbManager, gristConfig) { + return telemetry?.create(dbManager, gristConfig) ?? createDummyTelemetry(); + }, NSandbox(options) { return createSandbox(opts.sandboxFlavor || 'unsandboxed', options); }, diff --git a/app/server/lib/ProcessMonitor.ts b/app/server/lib/ProcessMonitor.ts index e8fba76a..e830e9d9 100644 --- a/app/server/lib/ProcessMonitor.ts +++ b/app/server/lib/ProcessMonitor.ts @@ -1,4 +1,5 @@ -import { TelemetryManager } from 'app/server/lib/TelemetryManager'; +import log from 'app/server/lib/log'; +import { ITelemetry } from 'app/server/lib/Telemetry'; const MONITOR_PERIOD_MS = 5_000; // take a look at memory usage this often const MEMORY_DELTA_FRACTION = 0.1; // fraction by which usage should change to get reported @@ -16,7 +17,7 @@ let _lastReportedCpuAverage: number = 0; * Monitor process memory (heap) and CPU usage, reporting as telemetry on an interval, and more * often when usage ticks up or down by a big enough delta. * - * There is a single global process monitor, reporting to the telemetryManager passed into the + * There is a single global process monitor, reporting to the `telemetry` object passed into the * first call to start(). * * Returns a function that stops the monitor, or null if there was already a process monitor @@ -30,12 +31,12 @@ let _lastReportedCpuAverage: number = 0; * - intervalMs: Interval (in milliseconds) over which cpuAverage is reported. Being much * higher than MONITOR_PERIOD_MS is a sign of being CPU bound for that long. */ -export function start(telemetryManager: TelemetryManager): (() => void) | undefined { +export function start(telemetry: ITelemetry): (() => void) | undefined { if (!_timer) { // Initialize variables needed for accurate first-tick measurement. _lastTickTime = Date.now(); _lastCpuUsage = process.cpuUsage(); - _timer = setInterval(() => monitor(telemetryManager), MONITOR_PERIOD_MS); + _timer = setInterval(() => monitor(telemetry), MONITOR_PERIOD_MS); return function stop() { clearInterval(_timer); @@ -44,7 +45,7 @@ export function start(telemetryManager: TelemetryManager): (() => void) | undefi } } -function monitor(telemetryManager: TelemetryManager) { +function monitor(telemetry: ITelemetry) { const memoryUsage = process.memoryUsage(); const heapUsed = memoryUsage.heapUsed; const cpuUsage = process.cpuUsage(); @@ -66,12 +67,15 @@ function monitor(telemetryManager: TelemetryManager) { Math.abs(heapUsed - _lastReportedHeapUsed) > _lastReportedHeapUsed * MEMORY_DELTA_FRACTION || Math.abs(cpuAverage - _lastReportedCpuAverage) > CPU_DELTA_FRACTION ) { - telemetryManager.logEvent('processMonitor', { - heapUsedMB: Math.round(memoryUsage.heapUsed/1024/1024), - heapTotalMB: Math.round(memoryUsage.heapTotal/1024/1024), - cpuAverage: Math.round(cpuAverage * 100) / 100, - intervalMs, - }); + telemetry.logEvent('processMonitor', { + full: { + heapUsedMB: Math.round(memoryUsage.heapUsed/1024/1024), + heapTotalMB: Math.round(memoryUsage.heapTotal/1024/1024), + cpuAverage: Math.round(cpuAverage * 100) / 100, + intervalMs, + }, + }) + .catch(e => log.error('failed to log telemetry event processMonitor', e)); _lastReportedHeapUsed = heapUsed; _lastReportedCpuAverage = cpuAverage; _lastReportTime = now; diff --git a/app/server/lib/Telemetry.ts b/app/server/lib/Telemetry.ts new file mode 100644 index 00000000..1a559cc8 --- /dev/null +++ b/app/server/lib/Telemetry.ts @@ -0,0 +1,210 @@ +import {ApiError} from 'app/common/ApiError'; +import { + buildTelemetryEventChecker, + filterMetadata, + removeNullishKeys, + TelemetryEvent, + TelemetryEventChecker, + TelemetryEvents, + TelemetryLevel, + TelemetryLevels, + TelemetryMetadata, + TelemetryMetadataByLevel, +} from 'app/common/Telemetry'; +import {HomeDBManager, HomeDBTelemetryEvents} from 'app/gen-server/lib/HomeDBManager'; +import {RequestWithLogin} from 'app/server/lib/Authorizer'; +import {GristServer} from 'app/server/lib/GristServer'; +import {LogMethods} from 'app/server/lib/LogMethods'; +import {stringParam} from 'app/server/lib/requestUtils'; +import * as express from 'express'; +import merge = require('lodash/merge'); + +export interface ITelemetry { + logEvent(name: TelemetryEvent, metadata?: TelemetryMetadataByLevel): Promise; + addEndpoints(app: express.Express): void; + getTelemetryLevel(): TelemetryLevel; +} + +/** + * Manages telemetry for Grist. + */ +export class Telemetry implements ITelemetry { + private _telemetryLevel: TelemetryLevel; + private _deploymentType = this._gristServer.getDeploymentType(); + private _shouldForwardTelemetryEvents = this._deploymentType !== 'saas'; + private _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL || + 'https://telemetry.getgrist.com/api/telemetry'; + + private _installationId: string | undefined; + + private _errorLogger = new LogMethods('Telemetry ', () => ({})); + private _telemetryLogger = new LogMethods('Telemetry ', () => ({ + eventType: 'telemetry', + })); + + private _checkEvent: TelemetryEventChecker | undefined; + + constructor(private _dbManager: HomeDBManager, private _gristServer: GristServer) { + this._initialize().catch((e) => { + this._errorLogger.error(undefined, 'failed to initialize', e); + }); + } + + /** + * Logs a telemetry `event` and its `metadata`. + * + * Depending on the deployment type, this will either forward the + * data to an endpoint (set via GRIST_TELEMETRY_URL) or log it + * directly. In hosted Grist, telemetry is logged directly, and + * subsequently sent to an OpenSearch instance via CloudWatch. In + * other deployment types, telemetry is forwarded to an endpoint + * of hosted Grist, which then handles logging to OpenSearch. + * + * Note that `metadata` is grouped by telemetry level, with only the + * groups meeting the current telemetry level being included in + * what's logged. If the current telemetry level is `off`, nothing + * will be logged. Otherwise, `metadata` will be filtered according + * to the current telemetry level, keeping only the groups that are + * less than or equal to the current level. + * + * Additionally, runtime checks are also performed to verify that the + * event and metadata being passed in are being logged appropriately + * for the configured telemetry level. If any checks fail, an error + * is thrown. + * + * Example: + * + * The following will only log the `rowCount` if the telemetry level is set + * to `limited`, and will log both the `method` and `userId` if the telemetry + * level is set to `full`: + * + * ``` + * logEvent('documentUsage', { + * limited: { + * rowCount: 123, + * }, + * full: { + * userId: 1586, + * }, + * }); + * ``` + */ + public async logEvent( + event: TelemetryEvent, + metadata?: TelemetryMetadataByLevel + ) { + if (this._telemetryLevel === 'off') { return; } + + metadata = filterMetadata(metadata, this._telemetryLevel); + this._checkTelemetryEvent(event, metadata); + + if (this._shouldForwardTelemetryEvents) { + await this.forwardEvent(event, metadata); + } else { + this._telemetryLogger.rawLog('info', null, event, { + eventName: event, + eventSource: `grist-${this._deploymentType}`, + ...metadata, + }); + } + } + + /** + * Forwards a telemetry event and its metadata to another server. + */ + public async forwardEvent( + event: TelemetryEvent, + metadata?: TelemetryMetadata + ) { + try { + await fetch(this._forwardTelemetryEventsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + event, + metadata, + }), + }); + } catch (e) { + this._errorLogger.error(undefined, `failed to forward telemetry event ${event}`, e); + } + } + + public addEndpoints(app: express.Application) { + /** + * Logs telemetry events and their metadata. + * + * Clients of this endpoint may be external Grist instances, so the behavior + * varies based on the presence of an `eventSource` key in the event metadata. + * + * If an `eventSource` key is present, the telemetry event will be logged + * directly, as the request originated from an external source; runtime checks + * of telemetry data are skipped since they should have already occured at the + * source. Otherwise, the event will only be logged after passing various + * checks. + */ + app.post('/api/telemetry', async (req, resp) => { + const mreq = req as RequestWithLogin; + const event = stringParam(req.body.event, 'event', TelemetryEvents.values); + if ('eventSource' in req.body.metadata) { + this._telemetryLogger.rawLog('info', null, event, { + eventName: event, + ...(removeNullishKeys(req.body.metadata)), + }); + } else { + try { + await this.logEvent(event as TelemetryEvent, merge( + { + limited: { + eventSource: `grist-${this._deploymentType}`, + ...(this._deploymentType !== 'saas' ? {installationId: this._installationId} : {}), + }, + full: { + userId: mreq.userId, + altSessionId: mreq.altSessionId, + }, + }, + req.body.metadata, + )); + } catch (e) { + this._errorLogger.error(undefined, `failed to log telemetry event ${event}`, e); + throw new ApiError(`Telemetry failed to log telemetry event ${event}`, 500); + } + } + return resp.status(200).send(); + }); + } + + public getTelemetryLevel() { + return this._telemetryLevel; + } + + private async _initialize() { + if (process.env.GRIST_TELEMETRY_LEVEL !== undefined) { + this._telemetryLevel = TelemetryLevels.check(process.env.GRIST_TELEMETRY_LEVEL); + this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryLevel); + } else { + this._telemetryLevel = 'off'; + } + + const {id} = await this._gristServer.getActivations().current(); + this._installationId = id; + + for (const event of HomeDBTelemetryEvents.values) { + this._dbManager.on(event, async (metadata) => { + this.logEvent(event, metadata).catch(e => + this._errorLogger.error(undefined, `failed to log telemetry event ${event}`, e)); + }); + } + } + + private _checkTelemetryEvent(event: TelemetryEvent, metadata?: TelemetryMetadata) { + if (!this._checkEvent) { + throw new Error('Telemetry._checkEvent is undefined'); + } + + this._checkEvent(event, metadata); + } +} diff --git a/app/server/lib/Triggers.ts b/app/server/lib/Triggers.ts index c6d95bdc..a27d93cd 100644 --- a/app/server/lib/Triggers.ts +++ b/app/server/lib/Triggers.ts @@ -630,7 +630,7 @@ export class DocTriggers { meta = {numEvents: batch.length, webhookId: id, host: new URL(url).host}; this._log("Sending batch of webhook events", meta); this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', { - numEvents: batch.length, + limited: {numEvents: meta.numEvents}, }); success = await this._sendWebhookWithRetries(id, url, body, batch.length, this._loopAbort.signal); if (this._loopAbort.signal.aborted) { diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index a571be15..15b06495 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -3,7 +3,7 @@ import {isAffirmative} from 'app/common/gutil'; import {getTagManagerSnippet} from 'app/common/tagManager'; import {Document} from 'app/common/UserAPI'; import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager'; -import {isAnonymousUser, RequestWithLogin} from 'app/server/lib/Authorizer'; +import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer'; import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {GristServer} from 'app/server/lib/GristServer'; import {getSupportedEngineChoices} from 'app/server/lib/serverUtils'; @@ -31,9 +31,16 @@ export interface ISendAppPageOptions { googleTagManager?: true | false | 'anon'; } -export function makeGristConfig(homeUrl: string|null, extra: Partial, - baseDomain?: string, req?: express.Request -): GristLoadConfig { +export interface MakeGristConfigOptons { + homeUrl: string|null; + extra: Partial; + baseDomain?: string; + req?: express.Request; + server?: GristServer|null; +} + +export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig { + const {homeUrl, extra, baseDomain, req, server} = options; // .invalid is a TLD the IETF promises will never exist. const pluginUrl = process.env.APP_UNTRUSTED_URL || 'http://plugins.invalid'; const pathOnly = (process.env.GRIST_ORG_IN_PATH === "true") || @@ -69,6 +76,8 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial { - // .invalid is a TLD the IETF promises will never exist. - const config = makeGristConfig(server ? server.getHomeUrl(req) : null, options.config, - opts.baseDomain, req); + const config = makeGristConfig({ + homeUrl: !isSingleUserMode() ? server.getHomeUrl(req) : null, + extra: options.config, + baseDomain: opts.baseDomain, + req, + server, + }); // We could cache file contents in memory, but the filesystem does caching too, and compared // to that, the performance gain is unlikely to be meaningful. So keep it simple here. @@ -112,7 +125,7 @@ export function makeSendAppPage(opts: { const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : ''; const staticOrigin = process.env.APP_STATIC_URL || ""; const staticBaseUrl = `${staticOrigin}/v/${options.tag || tag}/`; - const customHeadHtmlSnippet = server?.create.getExtraHeadHtml?.() ?? ""; + const customHeadHtmlSnippet = server.create.getExtraHeadHtml?.() ?? ""; const warning = testLogin ? "
Authentication is not enforced
" : ""; // Preload all languages that will be used or are requested by client. const preloads = req.languages @@ -127,7 +140,7 @@ export function makeSendAppPage(opts: { .replace("", warning) .replace("", getPageTitle(req, config)) .replace("", getPageMetadataHtmlSnippet(config)) - .replace("", getPageTitleSuffix(server?.getGristConfig())) + .replace("", getPageTitleSuffix(server.getGristConfig())) .replace("", `` + tagManagerSnippet) .replace("", preloads) .replace("", customHeadHtmlSnippet) @@ -150,6 +163,13 @@ function getFeatures(): IFeature[] { return Features.checkAll(difference(enabledFeatures, disabledFeatures)); } +function getTelemetryConfig(server: GristServer) { + const telemetry = server.getTelemetry(); + return { + telemetryLevel: telemetry.getTelemetryLevel(), + }; +} + function configuredPageTitleSuffix() { const result = process.env.GRIST_PAGE_TITLE_SUFFIX; return result === "_blank" ? "" : result; diff --git a/app/server/lib/serverUtils.ts b/app/server/lib/serverUtils.ts index 755bc422..c3ca3c22 100644 --- a/app/server/lib/serverUtils.ts +++ b/app/server/lib/serverUtils.ts @@ -7,6 +7,7 @@ import uuidv4 from 'uuid/v4'; import {AbortSignal} from 'node-abort-controller'; import {EngineCode} from 'app/common/DocumentSettings'; +import {TelemetryMetadataByLevel} from 'app/common/Telemetry'; import log from 'app/server/lib/log'; import {OpenMode, SQLiteDB} from 'app/server/lib/SQLiteDB'; import {getDocSessionAccessOrNull, getDocSessionUser, OptDocSession} from './DocSession'; @@ -166,14 +167,18 @@ export function getLogMetaFromDocSession(docSession: OptDocSession) { /** * Extract telemetry metadata from session. */ -export function getTelemetryMetaFromDocSession(docSession: OptDocSession) { +export function getTelemetryMetaFromDocSession(docSession: OptDocSession): TelemetryMetadataByLevel { const client = docSession.client; const access = getDocSessionAccessOrNull(docSession); const user = getDocSessionUser(docSession); return { - access, - ...(user ? {userId: user.id} : {}), - ...(client ? client.getFullTelemetryMeta() : {}), // Client if present will repeat and add to user info. + limited: { + access, + }, + full: { + ...(user ? {userId: user.id} : {}), + ...(client ? client.getFullTelemetryMeta() : {}), // Client if present will repeat and add to user info. + }, }; } diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts index 1396e18b..c4dddc52 100644 --- a/app/server/mergedServerMain.ts +++ b/app/server/mergedServerMain.ts @@ -119,19 +119,19 @@ export async function main(port: number, serverTypes: ServerType[], server.addHomeApi(); server.addBillingApi(); server.addNotifier(); + server.addTelemetry(); await server.addHousekeeper(); await server.addLoginRoutes(); server.addAccountPage(); server.addBillingPages(); server.addWelcomePaths(); server.addLogEndpoint(); - server.addTelemetryEndpoint(); server.addGoogleAuthEndpoint(); } if (includeDocs) { server.addJsonSupport(); - server.addTelemetryEndpoint(); + server.addTelemetry(); await server.addDoc(); } diff --git a/stubs/app/client/ui/BillingButtons.ts b/stubs/app/client/ui/BillingButtons.ts deleted file mode 100644 index 1d6b5f63..00000000 --- a/stubs/app/client/ui/BillingButtons.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {AppModel} from 'app/client/models/AppModel'; -import {DomElementArg} from 'grainjs'; - -export function buildUserMenuBillingItem( - _appModel: AppModel, - ..._args: DomElementArg[] -) { - return null; -} - -export function buildAppMenuBillingItem( - _appModel: AppModel, - ..._args: DomElementArg[] -) { - return null; -} diff --git a/stubs/app/client/ui/WelcomeCoachingCall.ts b/stubs/app/client/ui/WelcomeCoachingCall.ts deleted file mode 100644 index 27a45231..00000000 --- a/stubs/app/client/ui/WelcomeCoachingCall.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'app/client/ui/WelcomeCoachingCallStub'; diff --git a/stubs/app/server/lib/TelemetryManager.ts b/stubs/app/server/lib/TelemetryManager.ts deleted file mode 100644 index 128010db..00000000 --- a/stubs/app/server/lib/TelemetryManager.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {TelemetryEventName} from 'app/common/Telemetry'; -import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; - -export class TelemetryManager { - constructor(_dbManager: HomeDBManager) {} - - public logEvent( - _name: TelemetryEventName, - _metadata?: Record - ) {} -} diff --git a/stubs/app/server/lib/create.ts b/stubs/app/server/lib/create.ts index 20eca514..98c11b52 100644 --- a/stubs/app/server/lib/create.ts +++ b/stubs/app/server/lib/create.ts @@ -1,8 +1,10 @@ import { checkMinIOExternalStorage, configureMinIOExternalStorage } from 'app/server/lib/configureMinIOExternalStorage'; import { makeSimpleCreator } from 'app/server/lib/ICreate'; +import { Telemetry } from 'app/server/lib/Telemetry'; export const create = makeSimpleCreator({ + deploymentType: 'core', // This can and should be overridden by GRIST_SESSION_SECRET // (or generated randomly per install, like grist-omnibus does). sessionSecret: 'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh', @@ -13,4 +15,7 @@ export const create = makeSimpleCreator({ create: configureMinIOExternalStorage, }, ], + telemetry: { + create: (dbManager, gristServer) => new Telemetry(dbManager, gristServer), + } }); diff --git a/test/common/Telemetry.ts b/test/common/Telemetry.ts new file mode 100644 index 00000000..90dba2d8 --- /dev/null +++ b/test/common/Telemetry.ts @@ -0,0 +1,215 @@ +import {buildTelemetryEventChecker, filterMetadata, TelemetryEvent} from 'app/common/Telemetry'; +import {assert} from 'chai'; + +describe('Telemetry', function() { + describe('buildTelemetryEventChecker', function() { + it('returns a function that checks telemetry data', function() { + assert.isFunction(buildTelemetryEventChecker('full')); + }); + + it('does not throw if event and metadata are valid', function() { + const checker = buildTelemetryEventChecker('full'); + assert.doesNotThrow(() => checker('apiUsage', { + method: 'GET', + userId: 1, + userAgent: 'node-fetch/1.0', + })); + assert.doesNotThrow(() => checker('siteUsage', { + siteId: 1, + siteType: 'team', + inGoodStanding: true, + stripePlanId: 'stripePlanId', + numDocs: 1, + numWorkspaces: 1, + numMembers: 1, + lastActivity: new Date('2022-12-30T01:23:45'), + })); + assert.doesNotThrow(() => checker('watchedVideoTour', { + watchTimeSeconds: 30, + userId: 1, + altSessionId: 'altSessionId', + })); + }); + + it("does not throw when metadata is a subset of what's expected", function() { + const checker = buildTelemetryEventChecker('full'); + assert.doesNotThrow(() => checker('documentUsage', { + docIdDigest: 'docIdDigest', + siteId: 1, + rowCount: 123, + attachmentTypes: ['pdf'], + })); + }); + + it('does not throw if all metadata is less than or equal to the expected telemetry level', function() { + const checker = buildTelemetryEventChecker('limited'); + assert.doesNotThrow(() => checker('documentUsage', { + rowCount: 123, + })); + assert.doesNotThrow(() => checker('siteUsage', { + siteId: 1, + siteType: 'team', + inGoodStanding: true, + numDocs: 1, + numWorkspaces: 1, + numMembers: 1, + lastActivity: new Date('2022-12-30T01:23:45'), + })); + assert.doesNotThrow(() => checker('watchedVideoTour', { + watchTimeSeconds: 30, + })); + }); + + it('throws if event is invalid', function() { + const checker = buildTelemetryEventChecker('full'); + assert.throws( + () => checker('invalidEvent' as TelemetryEvent, {}), + /Unknown telemetry event: invalidEvent/ + ); + }); + + it('throws if metadata is invalid', function() { + const checker = buildTelemetryEventChecker('full'); + assert.throws( + () => checker('apiUsage', {invalidMetadata: '123'}), + /Unknown metadata for telemetry event apiUsage: invalidMetadata/ + ); + }); + + it('throws if metadata types do not match expected types', function() { + const checker = buildTelemetryEventChecker('full'); + assert.throws( + () => checker('siteUsage', {siteId: '1'}), + // eslint-disable-next-line max-len + /Telemetry metadata siteId of event siteUsage expected a value of type number but received a value of type string/ + ); + assert.throws( + () => checker('siteUsage', {lastActivity: 1234567890}), + // eslint-disable-next-line max-len + /Telemetry metadata lastActivity of event siteUsage expected a value of type Date or string but received a value of type number/ + ); + assert.throws( + () => checker('siteUsage', {inGoodStanding: 'true'}), + // eslint-disable-next-line max-len + /Telemetry metadata inGoodStanding of event siteUsage expected a value of type boolean but received a value of type string/ + ); + assert.throws( + () => checker('siteUsage', {numDocs: '1'}), + // eslint-disable-next-line max-len + /Telemetry metadata numDocs of event siteUsage expected a value of type number but received a value of type string/ + ); + assert.throws( + () => checker('documentUsage', {attachmentTypes: '1,2,3'}), + // eslint-disable-next-line max-len + /Telemetry metadata attachmentTypes of event documentUsage expected a value of type array but received a value of type string/ + ); + assert.throws( + () => checker('documentUsage', {attachmentTypes: ['.txt', 1, true]}), + // eslint-disable-next-line max-len + /Telemetry metadata attachmentTypes of event documentUsage expected a value of type string\[\] but received a value of type object\[\]/ + ); + }); + + it('throws if event requires an elevated telemetry level', function() { + const checker = buildTelemetryEventChecker('limited'); + assert.throws( + () => checker('signupVerified', {}), + // eslint-disable-next-line max-len + /Telemetry event signupVerified requires a minimum telemetry level of 2 but the current level is 1/ + ); + }); + + it('throws if metadata requires an elevated telemetry level', function() { + const checker = buildTelemetryEventChecker('limited'); + assert.throws( + () => checker('watchedVideoTour', { + watchTimeSeconds: 30, + userId: 1, + altSessionId: 'altSessionId', + }), + // eslint-disable-next-line max-len + /Telemetry metadata userId of event watchedVideoTour requires a minimum telemetry level of 2 but the current level is 1/ + ); + }); + }); + + describe('filterMetadata', function() { + it('returns filtered and flattened metadata when maxLevel is "full"', function() { + const metadata = { + limited: { + foo: 'abc', + }, + full: { + bar: '123', + }, + }; + assert.deepEqual(filterMetadata(metadata, 'full'), { + foo: 'abc', + bar: '123', + }); + }); + + it('returns filtered and flattened metadata when maxLevel is "limited"', function() { + const metadata = { + limited: { + foo: 'abc', + }, + full: { + bar: '123', + }, + }; + assert.deepEqual(filterMetadata(metadata, 'limited'), { + foo: 'abc', + }); + }); + + it('returns undefined when maxLevel is "off"', function() { + assert.isUndefined(filterMetadata(undefined, 'off')); + }); + + it('returns an empty object when metadata is empty', function() { + assert.isEmpty(filterMetadata({}, 'full')); + }); + + it('returns undefined when metadata is undefined', function() { + assert.isUndefined(filterMetadata(undefined, 'full')); + }); + + it('does not mutate metadata', function() { + const metadata = { + limited: { + foo: 'abc', + }, + full: { + bar: '123', + }, + }; + filterMetadata(metadata, 'limited'); + assert.deepEqual(metadata, { + limited: { + foo: 'abc', + }, + full: { + bar: '123', + }, + }); + }); + + it('excludes keys with nullish values', function() { + const metadata = { + limited: { + foo1: null, + foo2: 'abc', + }, + full: { + bar1: undefined, + bar2: '123', + }, + }; + assert.deepEqual(filterMetadata(metadata, 'full'), { + foo2: 'abc', + bar2: '123', + }); + }); + }); +}); diff --git a/test/nbrowser/testServer.ts b/test/nbrowser/testServer.ts index 758ae523..54b91da1 100644 --- a/test/nbrowser/testServer.ts +++ b/test/nbrowser/testServer.ts @@ -205,7 +205,11 @@ export class TestServerMerged implements IMochaServer { } const state: IGristUrlState = { org: team }; const baseDomain = parseSubdomain(new URL(this.getHost()).hostname).base; - const gristConfig = makeGristConfig(this.getHost(), {}, baseDomain); + const gristConfig = makeGristConfig({ + homeUrl: this.getHost(), + extra: {}, + baseDomain, + }); const url = encodeUrl(gristConfig, state, new URL(this.getHost())).replace(/\/$/, ""); return `${url}${relPath}`; } diff --git a/test/server/lib/Authorizer.ts b/test/server/lib/Authorizer.ts index 00d60aff..35dad0f6 100644 --- a/test/server/lib/Authorizer.ts +++ b/test/server/lib/Authorizer.ts @@ -34,7 +34,7 @@ async function activateServer(home: FlexServer, docManager: DocManager) { home.addJsonSupport(); await home.addLandingPages(); home.addHomeApi(); - home.addTelemetryEndpoint(); + home.addTelemetry(); await home.addDoc(); home.addApiErrorHandlers(); serverUrl = home.getOwnUrl(); diff --git a/test/server/lib/Telemetry.ts b/test/server/lib/Telemetry.ts new file mode 100644 index 00000000..cf065155 --- /dev/null +++ b/test/server/lib/Telemetry.ts @@ -0,0 +1,309 @@ +import {GristDeploymentType} from 'app/common/gristUrls'; +import {TelemetryEvent, TelemetryLevel, TelemetryMetadata} from 'app/common/Telemetry'; +import {ILogMeta, LogMethods} from 'app/server/lib/LogMethods'; +import {ITelemetry, Telemetry} from 'app/server/lib/Telemetry'; +import axios from 'axios'; +import {assert} from 'chai'; +import * as sinon from 'sinon'; +import {TestServer} from 'test/gen-server/apiUtils'; +import {configForUser} from 'test/gen-server/testUtils'; + +const chimpy = configForUser('Chimpy'); +const anon = configForUser('Anonymous'); + +describe('Telemetry', function() { + const deploymentTypesAndTelemetryLevels: [GristDeploymentType, TelemetryLevel][] = [ + ['saas', 'off'], + ['saas', 'limited'], + ['saas', 'full'], + ['core', 'off'], + ['core', 'limited'], + ['core', 'full'], + ]; + + for (const [deploymentType, telemetryLevel] of deploymentTypesAndTelemetryLevels) { + describe(`in grist-${deploymentType} with a telemetry level of "${telemetryLevel}"`, function() { + let homeUrl: string; + let installationId: string; + let server: TestServer; + let telemetry: ITelemetry; + + const sandbox = sinon.createSandbox(); + const loggedEvents: [TelemetryEvent, ILogMeta][] = []; + const forwardedEvents: [TelemetryEvent, TelemetryMetadata | undefined][] = []; + + before(async function() { + process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = deploymentType; + process.env.GRIST_TELEMETRY_LEVEL = telemetryLevel; + server = new TestServer(this); + homeUrl = await server.start(); + installationId = (await server.server.getActivations().current()).id; + sandbox + .stub(LogMethods.prototype, 'rawLog') + .callsFake((_level: string, _info: unknown, name: string, meta: ILogMeta) => { + loggedEvents.push([name as TelemetryEvent, meta]); + }); + sandbox + .stub(Telemetry.prototype, 'forwardEvent') + .callsFake((event: TelemetryEvent, metadata?: TelemetryMetadata) => { + forwardedEvents.push([event, metadata]); + }); + telemetry = server.server.getTelemetry(); + }); + + after(async function() { + await server.stop(); + sandbox.restore(); + delete process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE; + delete process.env.GRIST_TELEMETRY_LEVEL; + }); + + it('returns the current telemetry level', async function() { + assert.equal(telemetry.getTelemetryLevel(), telemetryLevel); + }); + + if (telemetryLevel !== 'off') { + if (deploymentType === 'saas') { + it('logs telemetry events', async function() { + if (telemetryLevel === 'limited') { + await telemetry.logEvent('documentOpened', { + limited: { + docIdDigest: 'digest', + isPublic: false, + }, + }); + assert.deepEqual(loggedEvents[loggedEvents.length - 1], [ + 'documentOpened', + { + eventName: 'documentOpened', + eventSource: `grist-${deploymentType}`, + docIdDigest: 'digest', + isPublic: false, + } + ]); + } + + if (telemetryLevel === 'full') { + await telemetry.logEvent('documentOpened', { + limited: { + docIdDigest: 'digest', + isPublic: false, + }, + full: { + userId: 1, + }, + }); + assert.deepEqual(loggedEvents[loggedEvents.length - 1], [ + 'documentOpened', + { + eventName: 'documentOpened', + eventSource: `grist-${deploymentType}`, + docIdDigest: 'digest', + isPublic: false, + userId: 1, + } + ]); + } + + assert.equal(loggedEvents.length, 1); + assert.isEmpty(forwardedEvents); + }); + } else { + it('forwards telemetry events', async function() { + if (telemetryLevel === 'limited') { + await telemetry.logEvent('documentOpened', { + limited: { + docIdDigest: 'digest', + isPublic: false, + }, + }); + assert.deepEqual(forwardedEvents[forwardedEvents.length - 1], [ + 'documentOpened', + { + docIdDigest: 'digest', + isPublic: false, + } + ]); + } + + if (telemetryLevel === 'full') { + await telemetry.logEvent('documentOpened', { + limited: { + docIdDigest: 'digest', + isPublic: false, + }, + full: { + userId: 1, + }, + }); + assert.deepEqual(forwardedEvents[forwardedEvents.length - 1], [ + 'documentOpened', + { + docIdDigest: 'digest', + isPublic: false, + userId: 1, + } + ]); + } + + assert.equal(forwardedEvents.length, 1); + assert.isEmpty(loggedEvents); + }); + } + } else { + it('does not log telemetry events', async function() { + await telemetry.logEvent('documentOpened', { + limited: { + docIdDigest: 'digest', + isPublic: false, + }, + }); + assert.isEmpty(loggedEvents); + assert.isEmpty(forwardedEvents); + }); + } + + if (telemetryLevel !== 'off') { + it('throws an error when an event is invalid', async function() { + await assert.isRejected( + telemetry.logEvent('invalidEvent' as TelemetryEvent, {limited: {method: 'GET'}}), + /Unknown telemetry event: invalidEvent/ + ); + }); + + it("throws an error when an event's metadata is invalid", async function() { + await assert.isRejected( + telemetry.logEvent('documentOpened', {limited: {invalidMetadata: 'GET'}}), + /Unknown metadata for telemetry event documentOpened: invalidMetadata/ + ); + }); + + if (telemetryLevel === 'limited') { + it('throws an error when an event requires an elevated telemetry level', async function() { + await assert.isRejected( + telemetry.logEvent('signupVerified', {}), + /Telemetry event signupVerified requires a minimum telemetry level of 2 but the current level is 1/ + ); + }); + + it("throws an error when an event's metadata requires an elevated telemetry level", async function() { + await assert.isRejected( + telemetry.logEvent('documentOpened', {limited: {userId: 1}}), + // eslint-disable-next-line max-len + /Telemetry metadata userId of event documentOpened requires a minimum telemetry level of 2 but the current level is 1/ + ); + }); + } + } + + if (telemetryLevel !== 'off') { + if (deploymentType === 'saas') { + it('logs telemetry events sent to /api/telemetry', async function() { + await axios.post(`${homeUrl}/api/telemetry`, { + event: 'watchedVideoTour', + metadata: { + limited: {watchTimeSeconds: 30}, + }, + }, chimpy); + const [event, metadata] = loggedEvents[loggedEvents.length - 1]; + assert.equal(event, 'watchedVideoTour'); + if (telemetryLevel === 'limited') { + assert.deepEqual(metadata, { + eventName: 'watchedVideoTour', + eventSource: `grist-${deploymentType}`, + watchTimeSeconds: 30, + }); + } else { + assert.containsAllKeys(metadata, [ + 'eventSource', + 'watchTimeSeconds', + 'userId', + 'altSessionId', + ]); + assert.equal(metadata.watchTimeSeconds, 30); + assert.equal(metadata.userId, 1); + } + + if (telemetryLevel === 'limited') { + assert.equal(loggedEvents.length, 2); + } else { + // The POST above also triggers an "apiUsage" event. + assert.equal(loggedEvents.length, 3); + assert.equal(loggedEvents[1][0], 'apiUsage'); + } + assert.isEmpty(forwardedEvents); + }); + + if (telemetryLevel === 'limited') { + it('skips checks if event sent to /api/telemetry is from an external source', async function() { + await axios.post(`${homeUrl}/api/telemetry`, { + event: 'watchedVideoTour', + metadata: { + eventSource: 'grist-core', + watchTimeSeconds: 60, + userId: 123, + altSessionId: 'altSessionId', + }, + }, anon); + const [event, metadata] = loggedEvents[loggedEvents.length - 1]; + assert.equal(event, 'watchedVideoTour'); + assert.containsAllKeys(metadata, [ + 'eventSource', + 'watchTimeSeconds', + 'userId', + 'altSessionId', + ]); + assert.equal(metadata.watchTimeSeconds, 60); + assert.equal(metadata.userId, 123); + assert.equal(loggedEvents.length, 3); + assert.isEmpty(forwardedEvents); + }); + } + } else { + it('forwards telemetry events sent to /api/telemetry', async function() { + await axios.post(`${homeUrl}/api/telemetry`, { + event: 'watchedVideoTour', + metadata: { + limited: {watchTimeSeconds: 30}, + }, + }, chimpy); + const [event, metadata] = forwardedEvents[forwardedEvents.length - 1]; + assert.equal(event, 'watchedVideoTour'); + if (telemetryLevel === 'limited') { + assert.deepEqual(metadata, { + eventSource: `grist-${deploymentType}`, + installationId, + watchTimeSeconds: 30, + }); + } else { + assert.containsAllKeys(metadata, [ + 'eventSource', + 'installationId', + 'watchTimeSeconds', + 'userId', + 'altSessionId', + ]); + assert.equal(metadata!.watchTimeSeconds, 30); + assert.equal(metadata!.userId, 1); + } + + if (telemetryLevel === 'limited') { + assert.equal(forwardedEvents.length, 2); + } else { + // The POST above also triggers an "apiUsage" event. + assert.equal(forwardedEvents.length, 3); + assert.equal(forwardedEvents[1][0], 'apiUsage'); + } + assert.isEmpty(loggedEvents); + }); + } + } else { + it('does not log telemetry events sent to /api/telemetry', async function() { + await telemetry.logEvent('apiUsage', {limited: {method: 'GET'}}); + assert.isEmpty(loggedEvents); + assert.isEmpty(forwardedEvents); + }); + } + }); + } +});