mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
parent
0d082c9cfc
commit
10f5f0cb37
@ -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.
|
||||
|
@ -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},
|
||||
}));
|
||||
}
|
||||
|
||||
|
@ -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<string, any>) {
|
||||
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<st
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
}).catch((e: Error) => {
|
||||
console.warn(`Failed to log telemetry event ${name}`, e);
|
||||
console.warn(`Failed to log telemetry event ${event}`, e);
|
||||
logError(e);
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -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', `
|
||||
|
@ -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 {
|
||||
|
@ -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())},
|
||||
});
|
||||
});
|
||||
|
||||
|
162
app/client/ui/WelcomeCoachingCall.ts
Normal file
162
app/client/ui/WelcomeCoachingCall.ts
Normal file
@ -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;
|
||||
`);
|
@ -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) {
|
||||
|
||||
}
|
@ -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<TelemetryEvent, TelemetryEventContract>;
|
||||
|
||||
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;
|
||||
|
||||
export type TelemetryEventName = typeof TelemetryEventNames[number];
|
||||
interface TelemetryEventContract {
|
||||
minimumTelemetryLevel: Level;
|
||||
metadataContracts?: Record<string, MetadataContract>;
|
||||
}
|
||||
|
||||
interface MetadataContract {
|
||||
dataType: 'boolean' | 'number' | 'string' | 'string[]' | 'date';
|
||||
minimumTelemetryLevel?: Level;
|
||||
}
|
||||
|
||||
export type TelemetryMetadataByLevel = Partial<Record<EnabledTelemetryLevel, TelemetryMetadata>>;
|
||||
|
||||
export type EnabledTelemetryLevel = Exclude<TelemetryLevel, 'off'>;
|
||||
|
||||
export const TelemetryLevels = StringUnion('off', 'limited', 'full');
|
||||
export type TelemetryLevel = typeof TelemetryLevels.type;
|
||||
|
||||
export type TelemetryMetadata = Record<string, any>;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of `object` with all null and undefined keys removed.
|
||||
*/
|
||||
export function removeNullishKeys(object: Record<string, any>) {
|
||||
return pickBy(object, value => value !== null && value !== undefined);
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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<string, any>
|
||||
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,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -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: '/',
|
||||
|
@ -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();
|
||||
|
@ -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<string, any> = {};
|
||||
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; }
|
||||
|
@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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<DocTemplate>;
|
||||
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'; },
|
||||
};
|
||||
}
|
||||
|
@ -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<void>;
|
||||
@ -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<string, SpawnFn>,
|
||||
}): 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);
|
||||
},
|
||||
|
@ -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;
|
||||
|
210
app/server/lib/Telemetry.ts
Normal file
210
app/server/lib/Telemetry.ts
Normal file
@ -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<void>;
|
||||
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);
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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<GristLoadConfig>,
|
||||
baseDomain?: string, req?: express.Request
|
||||
): GristLoadConfig {
|
||||
export interface MakeGristConfigOptons {
|
||||
homeUrl: string|null;
|
||||
extra: Partial<GristLoadConfig>;
|
||||
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<GristLoadCo
|
||||
featureFormulaAssistant: isAffirmative(process.env.GRIST_FORMULA_ASSISTANT),
|
||||
supportEmail: SUPPORT_EMAIL,
|
||||
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
|
||||
telemetry: server ? getTelemetryConfig(server) : undefined,
|
||||
deploymentType: server?.getDeploymentType(),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
@ -94,14 +103,18 @@ export function makeMessagePage(staticDir: string) {
|
||||
* placeholders replaced.
|
||||
*/
|
||||
export function makeSendAppPage(opts: {
|
||||
server: GristServer|null, staticDir: string, tag: string, testLogin?: boolean,
|
||||
server: GristServer, staticDir: string, tag: string, testLogin?: boolean,
|
||||
baseDomain?: string
|
||||
}) {
|
||||
const {server, staticDir, tag, testLogin} = opts;
|
||||
return async (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => {
|
||||
// .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 ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
|
||||
// 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("<!-- INSERT WARNING -->", warning)
|
||||
.replace("<!-- INSERT TITLE -->", getPageTitle(req, config))
|
||||
.replace("<!-- INSERT META -->", getPageMetadataHtmlSnippet(config))
|
||||
.replace("<!-- INSERT TITLE SUFFIX -->", getPageTitleSuffix(server?.getGristConfig()))
|
||||
.replace("<!-- INSERT TITLE SUFFIX -->", getPageTitleSuffix(server.getGristConfig()))
|
||||
.replace("<!-- INSERT BASE -->", `<base href="${staticBaseUrl}">` + tagManagerSnippet)
|
||||
.replace("<!-- INSERT LOCALE -->", preloads)
|
||||
.replace("<!-- INSERT CUSTOM -->", 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;
|
||||
|
@ -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.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from 'app/client/ui/WelcomeCoachingCallStub';
|
@ -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<string, any>
|
||||
) {}
|
||||
}
|
@ -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),
|
||||
}
|
||||
});
|
||||
|
215
test/common/Telemetry.ts
Normal file
215
test/common/Telemetry.ts
Normal file
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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}`;
|
||||
}
|
||||
|
@ -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();
|
||||
|
309
test/server/lib/Telemetry.ts
Normal file
309
test/server/lib/Telemetry.ts
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
Loading…
Reference in New Issue
Block a user