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/D3880pull/532/head
parent
0d082c9cfc
commit
10f5f0cb37
@ -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) {
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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>
|
||||
) {}
|
||||
}
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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