mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +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:
@@ -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) {
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user