(core) Add additional telemetry events

Summary: The new events capture usage of forms, widgets, access rules, and onboarding tours and tips.

Test Plan: Manual.

Reviewers: dsagal

Reviewed By: dsagal

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D4189
This commit is contained in:
George Gevoian 2024-02-13 12:49:00 -05:00
parent 7f9e2817d1
commit b8f32d1784
25 changed files with 483 additions and 55 deletions

View File

@ -8,6 +8,7 @@ import {aclSelect} from 'app/client/aclui/ACLSelect';
import {ACLUsersPopup} from 'app/client/aclui/ACLUsers'; import {ACLUsersPopup} from 'app/client/aclui/ACLUsers';
import {PermissionKey, permissionsWidget} from 'app/client/aclui/PermissionsWidget'; import {PermissionKey, permissionsWidget} from 'app/client/aclui/PermissionsWidget';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {reportError, UserError} from 'app/client/models/errors'; import {reportError, UserError} from 'app/client/models/errors';
import {TableData} from 'app/client/models/TableData'; import {TableData} from 'app/client/models/TableData';
import {shadowScroll} from 'app/client/ui/shadowScroll'; import {shadowScroll} from 'app/client/ui/shadowScroll';
@ -308,6 +309,13 @@ export class AccessRules extends Disposable {
}); });
} }
logTelemetryEvent('changedAccessRules', {
full: {
docIdDigest: this.gristDoc.docId(),
ruleCount: newRules.length,
},
});
// We need to fill in rulePos values. We'll add them in the order the rules are listed (since // We need to fill in rulePos values. We'll add them in the order the rules are listed (since
// this.getRules() returns them in a suitable order), keeping rulePos unchanged when possible. // this.getRules() returns them in a suitable order), keeping rulePos unchanged when possible.
let lastGoodRulePos = 0; let lastGoodRulePos = 0;

View File

@ -1,4 +1,5 @@
import {showBehavioralPrompt} from 'app/client/components/modals'; import {showBehavioralPrompt} from 'app/client/components/modals';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {getUserPrefObs} from 'app/client/models/UserPrefs'; import {getUserPrefObs} from 'app/client/models/UserPrefs';
import {GristBehavioralPrompts} from 'app/client/ui/GristTooltips'; import {GristBehavioralPrompts} from 'app/client/ui/GristTooltips';
@ -159,6 +160,8 @@ export class BehavioralPromptsManager extends Disposable {
}); });
dom.onElem(refElement, 'click', () => close()); dom.onElem(refElement, 'click', () => close());
dom.onDisposeElem(refElement, () => close()); dom.onDisposeElem(refElement, () => close());
logTelemetryEvent('viewedTip', {full: {tipName: prompt}});
} }
private _showNextQueuedTip() { private _showNextQueuedTip() {

View File

@ -2,7 +2,7 @@ import {cssBannerLink} from 'app/client/components/Banner';
import {DocPageModel} from 'app/client/models/DocPageModel'; import {DocPageModel} from 'app/client/models/DocPageModel';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {docListHeader} from 'app/client/ui/DocMenuCss'; import {docListHeader} from 'app/client/ui/DocMenuCss';
import {GristTooltips, TooltipContentFunc} from 'app/client/ui/GristTooltips'; import {Tooltip} from 'app/client/ui/GristTooltips';
import {withInfoTooltip} from 'app/client/ui/tooltips'; import {withInfoTooltip} from 'app/client/ui/tooltips';
import {mediaXSmall, theme} from 'app/client/ui2018/cssVars'; import {mediaXSmall, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
@ -81,7 +81,7 @@ export class DocumentUsage extends Disposable {
maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE, maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE,
unit: 'MB', unit: 'MB',
shouldHideLimits: maxValue === undefined, shouldHideLimits: maxValue === undefined,
tooltipContentFunc: GristTooltips.dataSize, tooltip: 'dataSize',
formatValue: (val) => { formatValue: (val) => {
// To display a nice, round number for `maximumValue`, we first convert // To display a nice, round number for `maximumValue`, we first convert
// to KiBs (base-2), and then convert to MBs (base-10). Normally, we wouldn't // to KiBs (base-2), and then convert to MBs (base-10). Normally, we wouldn't
@ -271,7 +271,7 @@ interface MetricOptions {
// If true, limits will always be hidden, even if `maximumValue` is a positive number. // If true, limits will always be hidden, even if `maximumValue` is a positive number.
shouldHideLimits?: boolean; shouldHideLimits?: boolean;
// Shows an icon next to the metric name that displays a tooltip on hover. // Shows an icon next to the metric name that displays a tooltip on hover.
tooltipContentFunc?: TooltipContentFunc; tooltip?: Tooltip;
formatValue?(value: number): string; formatValue?(value: number): string;
} }
@ -281,14 +281,11 @@ interface MetricOptions {
* close `currentValue` is to hitting `maximumValue`. * close `currentValue` is to hitting `maximumValue`.
*/ */
function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) { function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
const {name, tooltipContentFunc} = options; const {name, tooltip} = options;
return cssUsageMetric( return cssUsageMetric(
cssMetricName( cssMetricName(
tooltipContentFunc tooltip
? withInfoTooltip( ? withInfoTooltip(cssOverflowableText(name, testId('name')), tooltip)
cssOverflowableText(name, testId('name')),
tooltipContentFunc()
)
: cssOverflowableText(name, testId('name')), : cssOverflowableText(name, testId('name')),
), ),
buildUsageProgressBar(options), buildUsageProgressBar(options),

View File

@ -11,6 +11,7 @@ import {Disposable} from 'app/client/lib/dispose';
import {AsyncComputed, makeTestId, stopEvent} from 'app/client/lib/domUtils'; import {AsyncComputed, makeTestId, stopEvent} from 'app/client/lib/domUtils';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import DataTableModel from 'app/client/models/DataTableModel'; import DataTableModel from 'app/client/models/DataTableModel';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {ShareRec} from 'app/client/models/entities/ShareRec'; import {ShareRec} from 'app/client/models/entities/ShareRec';
@ -514,6 +515,13 @@ export class FormView extends Disposable {
throw ex; throw ex;
} }
} }
logTelemetryEvent('publishedForm', {
full: {
docIdDigest: this.gristDoc.docId(),
},
});
await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => { await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => {
if (!validShare) { if (!validShare) {
const shareRef = await this.gristDoc.docModel.docData.sendAction([ const shareRef = await this.gristDoc.docModel.docData.sendAction([
@ -573,6 +581,12 @@ export class FormView extends Disposable {
} }
private async _unpublishForm() { private async _unpublishForm() {
logTelemetryEvent('unpublishedForm', {
full: {
docIdDigest: this.gristDoc.docId(),
},
});
await this.gristDoc.docModel.docData.bundleActions('Unpublish form', async () => { await this.gristDoc.docModel.docData.bundleActions('Unpublish form', async () => {
this.viewSection.shareOptionsObj.update({ this.viewSection.shareOptionsObj.update({
publish: false, publish: false,

View File

@ -47,9 +47,10 @@ import {DocTutorial} from 'app/client/ui/DocTutorial';
import {DocSettingsPage} from 'app/client/ui/DocumentSettings'; import {DocSettingsPage} from 'app/client/ui/DocumentSettings';
import {isTourActive} from "app/client/ui/OnBoardingPopups"; import {isTourActive} from "app/client/ui/OnBoardingPopups";
import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, selectBy} from 'app/client/ui/selectBy'; import {linkFromId, NoLink, selectBy} from 'app/client/ui/selectBy';
import {WebhookPage} from 'app/client/ui/WebhookPage'; import {WebhookPage} from 'app/client/ui/WebhookPage';
import {startWelcomeTour} from 'app/client/ui/WelcomeTour'; import {startWelcomeTour} from 'app/client/ui/WelcomeTour';
import {getTelemetryWidgetTypeFromPageWidget} from 'app/client/ui/widgetTypesMap';
import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer'; import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer';
import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList'; import {IconName} from 'app/client/ui2018/IconList';
@ -882,6 +883,13 @@ export class GristDoc extends DisposableWithEvents {
return; return;
} }
} }
const widgetType = getTelemetryWidgetTypeFromPageWidget(val);
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
if (val.link !== NoLink) {
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
}
const res: {sectionRef: number} = await docData.bundleActions( const res: {sectionRef: number} = await docData.bundleActions(
t("Added new linked section to view {{viewName}}", {viewName}), t("Added new linked section to view {{viewName}}", {viewName}),
() => this.addWidgetToPageImpl(val, tableId ?? null) () => this.addWidgetToPageImpl(val, tableId ?? null)
@ -932,6 +940,14 @@ export class GristDoc extends DisposableWithEvents {
* Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`. * Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`.
*/ */
public async addNewPage(val: IPageWidget) { public async addNewPage(val: IPageWidget) {
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
logTelemetryEvent('addedWidget', {
full: {
docIdDigest: this.docId(),
widgetType: getTelemetryWidgetTypeFromPageWidget(val),
},
});
if (val.table === 'New Table') { if (val.table === 'New Table') {
const name = await this._promptForName(); const name = await this._promptForName();
if (name === undefined) { if (name === undefined) {

View File

@ -4,12 +4,14 @@ import {DocumentUsage} from 'app/client/components/DocumentUsage';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {printViewSection} from 'app/client/components/Printing'; import {printViewSection} from 'app/client/components/Printing';
import {ViewSectionHelper} from 'app/client/components/ViewLayout'; import {ViewSectionHelper} from 'app/client/components/ViewLayout';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars'; import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs'; import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {ViewSectionRec} from 'app/client/models/DocModel'; import {ViewSectionRec} from 'app/client/models/DocModel';
import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom'; import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
import {getTelemetryWidgetTypeFromVS} from 'app/client/ui/widgetTypesMap';
const testId = makeTestId('test-raw-data-'); const testId = makeTestId('test-raw-data-');
@ -82,6 +84,10 @@ export class RawDataPopup extends Disposable {
if (this._viewSection.isRaw.peek()) { if (this._viewSection.isRaw.peek()) {
throw new Error("Can't delete a raw section"); throw new Error("Can't delete a raw section");
} }
const widgetType = getTelemetryWidgetTypeFromVS(this._viewSection);
logTelemetryEvent('deletedWidget', {full: {docIdDigest: this._gristDoc.docId(), widgetType}});
this._gristDoc.docData.sendAction(['RemoveViewSection', this._viewSection.id.peek()]).catch(reportError); this._gristDoc.docData.sendAction(['RemoveViewSection', this._viewSection.id.peek()]).catch(reportError);
}, },
}; };

View File

@ -14,8 +14,10 @@ import {LayoutTray} from 'app/client/components/LayoutTray';
import {printViewSection} from 'app/client/components/Printing'; import {printViewSection} from 'app/client/components/Printing';
import {Delay} from 'app/client/lib/Delay'; import {Delay} from 'app/client/lib/Delay';
import {createObsArray} from 'app/client/lib/koArrayWrap'; import {createObsArray} from 'app/client/lib/koArrayWrap';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {getTelemetryWidgetTypeFromVS} from 'app/client/ui/widgetTypesMap';
import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
@ -279,6 +281,14 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
// more than one viewsection in the view. // more than one viewsection in the view.
public removeViewSection(viewSectionRowId: number) { public removeViewSection(viewSectionRowId: number) {
this.maximized.set(null); this.maximized.set(null);
const viewSection = this.viewModel.viewSections().all().find(s => s.getRowId() === viewSectionRowId);
if (!viewSection) {
throw new Error(`Section not found: ${viewSectionRowId}`);
}
const widgetType = getTelemetryWidgetTypeFromVS(viewSection);
logTelemetryEvent('deletedWidget', {full: {docIdDigest: this.gristDoc.docId(), widgetType}});
this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError); this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError);
} }

View File

@ -1,4 +1,5 @@
import { GristDoc } from 'app/client/components/GristDoc'; import { GristDoc } from 'app/client/components/GristDoc';
import { logTelemetryEvent } from 'app/client/lib/telemetry';
import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel'; import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel';
import { cssInput } from 'app/client/ui/cssInput'; import { cssInput } from 'app/client/ui/cssInput';
import { cssField, cssLabel } from 'app/client/ui/MakeCopyMenu'; import { cssField, cssLabel } from 'app/client/ui/MakeCopyMenu';
@ -43,6 +44,8 @@ async function makeDuplicate(gristDoc: GristDoc, pageId: number, pageName: strin
await gristDoc.docData.bundleActions( await gristDoc.docData.bundleActions(
t("Duplicate page {{pageName}}", {pageName}), t("Duplicate page {{pageName}}", {pageName}),
async () => { async () => {
logTelemetryEvent('addedPage', {full: {docIdDigest: gristDoc.docId()}});
// create new view and new sections // create new view and new sections
const results = await createNewViewSections(gristDoc.docData, viewSections); const results = await createNewViewSections(gristDoc.docData, viewSections);
viewRef = results[0].viewRef; viewRef = results[0].viewRef;

View File

@ -2,7 +2,6 @@ import {makeT} from 'app/client/lib/localization';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec'; import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight'; import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {withInfoTooltip} from 'app/client/ui/tooltips'; import {withInfoTooltip} from 'app/client/ui/tooltips';
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas'; import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
@ -359,7 +358,7 @@ export function buildFormulaConfig(
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)), dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
testId("field-set-trigger") testId("field-set-trigger")
), ),
GristTooltips.setTriggerFormula(), 'setTriggerFormula',
)), )),
cssRow(textButton( cssRow(textButton(
t("Make into data column"), t("Make into data column"),
@ -412,7 +411,7 @@ export function buildFormulaConfig(
dom.prop("disabled", disableOtherActions), dom.prop("disabled", disableOtherActions),
testId("field-set-trigger") testId("field-set-trigger")
), ),
GristTooltips.setTriggerFormula() 'setTriggerFormula'
)), )),
]) ])
]) ])

View File

@ -4,7 +4,6 @@ import GridView from 'app/client/components/GridView';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {ColumnRec} from "app/client/models/entities/ColumnRec"; import {ColumnRec} from "app/client/models/entities/ColumnRec";
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {withInfoTooltip} from 'app/client/ui/tooltips'; import {withInfoTooltip} from 'app/client/ui/tooltips';
import {isNarrowScreen, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {isNarrowScreen, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {IconName} from "app/client/ui2018/IconList"; import {IconName} from "app/client/ui2018/IconList";
@ -136,7 +135,7 @@ function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomEle
}, },
withInfoTooltip( withInfoTooltip(
t('Add formula column'), t('Add formula column'),
GristTooltips.formulaColumn(), 'formulaColumn',
{variant: 'hover'} {variant: 'hover'}
), ),
testId('new-columns-menu-add-formula'), testId('new-columns-menu-add-formula'),
@ -385,7 +384,7 @@ function buildUUIDMenuItem(gridView: GridView, index?: number) {
}, },
withInfoTooltip( withInfoTooltip(
t('UUID'), t('UUID'),
GristTooltips.uuid(), 'uuid',
{variant: 'hover'} {variant: 'hover'}
), ),
testId('new-columns-menu-shortcuts-uuid'), testId('new-columns-menu-shortcuts-uuid'),
@ -680,7 +679,7 @@ function buildLookupSection(gridView: GridView, index?: number){
menuSubHeader( menuSubHeader(
withInfoTooltip( withInfoTooltip(
t('Lookups'), t('Lookups'),
GristTooltips.lookups(), 'lookups',
{variant: 'hover'} {variant: 'hover'}
), ),
testId('new-columns-menu-lookups'), testId('new-columns-menu-lookups'),

View File

@ -38,7 +38,7 @@ export type Tooltip =
| 'addColumnConditionalStyle' | 'addColumnConditionalStyle'
| 'uuid' | 'uuid'
| 'lookups' | 'lookups'
| 'formulaColumn' | 'formulaColumn';
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents; export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;

View File

@ -78,7 +78,7 @@ export interface IOnBoardingMsg {
// starting a new one. // starting a new one.
const tourSingleton = Holder.create<OnBoardingPopupsCtl>(null); const tourSingleton = Holder.create<OnBoardingPopupsCtl>(null);
export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => void) { export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: (lastMessageIndex: number) => void) {
const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB); const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB);
ctl.start().catch(reportError); ctl.start().catch(reportError);
} }
@ -109,7 +109,7 @@ class OnBoardingPopupsCtl extends Disposable {
private _overlay: HTMLElement; private _overlay: HTMLElement;
private _arrowEl = buildArrow(); private _arrowEl = buildArrow();
constructor(private _messages: IOnBoardingMsg[], private _onFinishCB: () => void) { constructor(private _messages: IOnBoardingMsg[], private _onFinishCB: (lastMessageIndex: number) => void) {
super(); super();
if (this._messages.length === 0) { if (this._messages.length === 0) {
throw new OnBoardingError('messages should not be an empty list'); throw new OnBoardingError('messages should not be an empty list');
@ -133,8 +133,8 @@ class OnBoardingPopupsCtl extends Disposable {
}); });
} }
private _finish() { private _finish(lastMessageIndex: number) {
this._onFinishCB(); this._onFinishCB(lastMessageIndex);
this.dispose(); this.dispose();
} }
@ -143,9 +143,9 @@ class OnBoardingPopupsCtl extends Disposable {
const entry = this._messages[newIndex]; const entry = this._messages[newIndex];
if (!entry) { if (!entry) {
if (maybeClose) { if (maybeClose) {
this._finish(ctlIndex);
// User finished the tour, close and restart from the beginning if they reopen // User finished the tour, close and restart from the beginning if they reopen
ctlIndex = 0; ctlIndex = 0;
this._finish();
} }
return; // gone out of bounds, probably by keyboard shortcut return; // gone out of bounds, probably by keyboard shortcut
} }
@ -266,7 +266,7 @@ class OnBoardingPopupsCtl extends Disposable {
this._arrowEl, this._arrowEl,
ContentWrapper( ContentWrapper(
cssCloseButton(cssBigIcon('CrossBig'), cssCloseButton(cssBigIcon('CrossBig'),
dom.on('click', () => this._finish()), dom.on('click', () => this._finish(ctlIndex)),
testId('close'), testId('close'),
), ),
cssTitle(this._messages[ctlIndex].title), cssTitle(this._messages[ctlIndex].title),
@ -275,7 +275,7 @@ class OnBoardingPopupsCtl extends Disposable {
testId('popup'), testId('popup'),
), ),
dom.onKeyDown({ dom.onKeyDown({
Escape: () => this._finish(), Escape: () => this._finish(ctlIndex),
ArrowLeft: () => this._move(-1), ArrowLeft: () => this._move(-1),
ArrowRight: () => this._move(+1), ArrowRight: () => this._move(+1),
Enter: () => this._move(+1, true), Enter: () => this._move(+1, true),

View File

@ -4,7 +4,6 @@ import {makeT} from 'app/client/lib/localization';
import {reportError} from 'app/client/models/AppModel'; import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel';
import {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features"; import {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features";
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {linkId, NoLink} from 'app/client/ui/selectBy'; import {linkId, NoLink} from 'app/client/ui/selectBy';
import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
import {getWidgetTypes} from "app/client/ui/widgetTypesMap"; import {getWidgetTypes} from "app/client/ui/widgetTypesMap";
@ -394,7 +393,7 @@ export class PageWidgetSelect extends Disposable {
dom.update(cssSelect(this._value.link, this._selectByOptions!), dom.update(cssSelect(this._value.link, this._selectByOptions!),
testId('selectby')) testId('selectby'))
), ),
GristTooltips.selectBy(), 'selectBy',
{popupOptions: {attach: null}, domArgs: [ {popupOptions: {attach: null}, domArgs: [
this._behavioralPromptsManager.attachTip('pageWidgetPickerSelectBy', { this._behavioralPromptsManager.attachTip('pageWidgetPickerSelectBy', {
popupOptions: { popupOptions: {

View File

@ -2,6 +2,7 @@ import {createGroup} from 'app/client/components/commands';
import {duplicatePage} from 'app/client/components/duplicatePage'; import {duplicatePage} from 'app/client/components/duplicatePage';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {PageRec} from 'app/client/models/DocModel'; import {PageRec} from 'app/client/models/DocModel';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import MetaTableModel from 'app/client/models/MetaTableModel'; import MetaTableModel from 'app/client/models/MetaTableModel';
@ -83,6 +84,8 @@ function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: Grist
} }
function removeView(activeDoc: GristDoc, viewId: number, pageName: string) { function removeView(activeDoc: GristDoc, viewId: number, pageName: string) {
logTelemetryEvent('deletedPage', {full: {docIdDigest: activeDoc.docId()}});
const docData = activeDoc.docData; const docData = activeDoc.docData;
// Create a set with tables on other pages (but not on this one). // Create a set with tables on other pages (but not on this one).
const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels

View File

@ -25,6 +25,7 @@ import {domAsync} from 'app/client/lib/domAsync';
import * as imports from 'app/client/lib/imports'; import * as imports from 'app/client/lib/imports';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs'; import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {reportError} from 'app/client/models/AppModel'; import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
@ -35,9 +36,9 @@ import {textarea} from 'app/client/ui/inputs';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig"; import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig";
import {cssLabel} from 'app/client/ui/RightPanelStyles'; import {cssLabel} from 'app/client/ui/RightPanelStyles';
import {linkId, selectBy} from 'app/client/ui/selectBy'; import {linkId, NoLink, selectBy} from 'app/client/ui/selectBy';
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig'; import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
import {widgetTypesMap} from "app/client/ui/widgetTypesMap"; import {getTelemetryWidgetTypeFromVS, widgetTypesMap} from "app/client/ui/widgetTypesMap";
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {buttonSelect} from 'app/client/ui2018/buttonSelect'; import {buttonSelect} from 'app/client/ui2018/buttonSelect';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
@ -792,7 +793,16 @@ export class RightPanel extends Disposable {
); );
}); });
link.onWrite((val) => this._gristDoc.saveLink(val)); link.onWrite(async (val) => {
const widgetType = getTelemetryWidgetTypeFromVS(activeSection);
if (val !== NoLink) {
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this._gristDoc.docId(), widgetType}});
} else {
logTelemetryEvent('unlinkedWidget', {full: {docIdDigest: this._gristDoc.docId(), widgetType}});
}
await this._gristDoc.saveLink(val);
});
return [ return [
this._disableIfReadonly(), this._disableIfReadonly(),
cssLabel(t("DATA TABLE")), cssLabel(t("DATA TABLE")),

View File

@ -3,7 +3,6 @@ import {loadUserManager} from 'app/client/lib/imports';
import {AppModel, reportError} from 'app/client/models/AppModel'; import {AppModel, reportError} from 'app/client/models/AppModel';
import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel'; import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
import {docUrl, getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {docUrl, getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {downloadDocModal, makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu'; import {downloadDocModal, makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu';
import {sendToDrive} from 'app/client/ui/sendToDrive'; import {sendToDrive} from 'app/client/ui/sendToDrive';
import {hoverTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
@ -255,7 +254,7 @@ function menuWorkOnCopy(pageModel: DocPageModel) {
menuText( menuText(
withInfoTooltip( withInfoTooltip(
t("Edit without affecting the original"), t("Edit without affecting the original"),
GristTooltips.workOnACopy(), 'workOnACopy',
{popupOptions: {attach: null}} {popupOptions: {attach: null}}
) )
), ),

View File

@ -27,7 +27,6 @@ import {IEditableMember, IMemberSelectOption, IOrgMemberSelectOption,
Resource} from 'app/client/models/UserManagerModel'; Resource} from 'app/client/models/UserManagerModel';
import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel'; import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel';
import {getResourceParent, ResourceType} from 'app/client/models/UserManagerModel'; import {getResourceParent, ResourceType} from 'app/client/models/UserManagerModel';
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {shadowScroll} from 'app/client/ui/shadowScroll'; import {shadowScroll} from 'app/client/ui/shadowScroll';
import {hoverTooltip, ITooltipControl, showTransientTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip, ITooltipControl, showTransientTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
import {createUserImage} from 'app/client/ui/UserImage'; import {createUserImage} from 'app/client/ui/UserImage';
@ -187,7 +186,7 @@ function buildUserManagerModal(
}), }),
testId('um-open-access-rules'), testId('um-open-access-rules'),
), ),
GristTooltips.openAccessRules(), 'openAccessRules',
{domArgs: [cssAccessLink.cls('')]}, {domArgs: [cssAccessLink.cls('')]},
) )
: null : null

View File

@ -1,4 +1,5 @@
import { makeT } from 'app/client/lib/localization'; import { makeT } from 'app/client/lib/localization';
import { logTelemetryEvent } from 'app/client/lib/telemetry';
import * as commands from 'app/client/components/commands'; import * as commands from 'app/client/components/commands';
import { urlState } from 'app/client/models/gristUrlState'; import { urlState } from 'app/client/models/gristUrlState';
import { IOnBoardingMsg, startOnBoarding } from "app/client/ui/OnBoardingPopups"; import { IOnBoardingMsg, startOnBoarding } from "app/client/ui/OnBoardingPopups";
@ -100,7 +101,15 @@ export function getOnBoardingMessages(): IOnBoardingMsg[] {
export function startWelcomeTour(onFinishCB: () => void) { export function startWelcomeTour(onFinishCB: () => void) {
commands.allCommands.fieldTabOpen.run(); commands.allCommands.fieldTabOpen.run();
startOnBoarding(getOnBoardingMessages(), onFinishCB); const messages = getOnBoardingMessages();
startOnBoarding(messages, (lastMessageIndex) => {
logTelemetryEvent('viewedWelcomeTour', {
full: {
percentComplete: Math.floor(((lastMessageIndex + 1) / messages.length) * 100),
},
});
onFinishCB();
});
} }
const TopBarButtonIcon = styled(icon, ` const TopBarButtonIcon = styled(icon, `

View File

@ -5,6 +5,8 @@
* - to be shown briefly, as a transient notification next to some action element. * - to be shown briefly, as a transient notification next to some action element.
*/ */
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {GristTooltips, Tooltip} from 'app/client/ui/GristTooltips';
import {prepareForTransition} from 'app/client/ui/transitions'; import {prepareForTransition} from 'app/client/ui/transitions';
import {testId, theme, vars} from 'app/client/ui2018/cssVars'; import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
@ -312,6 +314,8 @@ export interface InfoTooltipOptions {
variant?: InfoTooltipVariant; variant?: InfoTooltipVariant;
/** Only applicable to the `click` variant. */ /** Only applicable to the `click` variant. */
popupOptions?: IPopupOptions; popupOptions?: IPopupOptions;
/** Only applicable to the `click` variant. */
onOpen?: () => void;
} }
export type InfoTooltipVariant = 'click' | 'hover'; export type InfoTooltipVariant = 'click' | 'hover';
@ -320,33 +324,42 @@ export type InfoTooltipVariant = 'click' | 'hover';
* Renders an info icon that shows a tooltip with the specified `content`. * Renders an info icon that shows a tooltip with the specified `content`.
*/ */
export function infoTooltip( export function infoTooltip(
content: DomContents, tooltip: Tooltip,
options: InfoTooltipOptions = {}, options: InfoTooltipOptions = {},
...domArgs: DomElementArg[] ...domArgs: DomElementArg[]
) { ) {
const {variant = 'click'} = options; const {variant = 'click'} = options;
const content = GristTooltips[tooltip]();
const onOpen = () => logTelemetryEvent('viewedTip', {full: {tipName: tooltip}});
switch (variant) { switch (variant) {
case 'click': { case 'click': {
const {popupOptions} = options; const {popupOptions} = options;
return buildClickableInfoTooltip(content, popupOptions, domArgs); return buildClickableInfoTooltip(content, {onOpen, popupOptions}, domArgs);
} }
case 'hover': { case 'hover': {
return buildHoverableInfoTooltip(content, domArgs); return buildHoverableInfoTooltip(content, domArgs);
} }
} }
}
export interface ClickableInfoTooltipOptions {
popupOptions?: IPopupOptions;
onOpen?: () => void;
} }
function buildClickableInfoTooltip( function buildClickableInfoTooltip(
content: DomContents, content: DomContents,
popupOptions?: IPopupOptions, options: ClickableInfoTooltipOptions = {},
...domArgs: DomElementArg[] ...domArgs: DomElementArg[]
) { ) {
const {onOpen, popupOptions} = options;
return cssInfoTooltipButton('?', return cssInfoTooltipButton('?',
(elem) => { (elem) => {
setPopupToCreateDom( setPopupToCreateDom(
elem, elem,
(ctl) => { (ctl) => {
onOpen?.();
return cssInfoTooltipPopup( return cssInfoTooltipPopup(
cssInfoTooltipPopupCloseButton( cssInfoTooltipPopupCloseButton(
icon('CrossSmall'), icon('CrossSmall'),
@ -395,11 +408,13 @@ export interface WithInfoTooltipOptions {
iconDomArgs?: DomElementArg[]; iconDomArgs?: DomElementArg[];
/** Only applicable to the `click` variant. */ /** Only applicable to the `click` variant. */
popupOptions?: IPopupOptions; popupOptions?: IPopupOptions;
onOpen?: () => void;
} }
/** /**
* Wraps `domContent` with a info tooltip icon that displays the provided * Wraps `domContent` with a info tooltip icon that displays the specified
* `tooltipContent` and returns the wrapped element. * `tooltip` and returns the wrapped element. Tooltips are defined in
* `app/client/ui/GristTooltips.ts`.
* *
* The tooltip button is displayed to the right of `domContents`, and displays * The tooltip button is displayed to the right of `domContents`, and displays
* a popup on click by default. The popup can be dismissed by clicking away from * a popup on click by default. The popup can be dismissed by clicking away from
@ -414,20 +429,17 @@ export interface WithInfoTooltipOptions {
* *
* Usage: * Usage:
* *
* withInfoTooltip( * withInfoTooltip(dom('div', 'Hello World!'), 'selectBy')
* dom('div', 'Hello World!'),
* dom('p', 'This is some text to show inside the tooltip.'),
* )
*/ */
export function withInfoTooltip( export function withInfoTooltip(
domContents: DomContents, domContents: DomContents,
tooltipContent: DomContents, tooltip: Tooltip,
options: WithInfoTooltipOptions = {}, options: WithInfoTooltipOptions = {},
) { ) {
const {variant = 'click', domArgs, iconDomArgs, popupOptions} = options; const {variant = 'click', domArgs, iconDomArgs, popupOptions} = options;
return cssDomWithTooltip( return cssDomWithTooltip(
domContents, domContents,
infoTooltip(tooltipContent, {variant, popupOptions}, iconDomArgs), infoTooltip(tooltip, {variant, popupOptions}, iconDomArgs),
...(domArgs ?? []) ...(domArgs ?? [])
); );
} }

View File

@ -1,6 +1,8 @@
// the list of widget types with their labels and icons // the list of widget types with their labels and icons
import {IWidgetType} from "app/common/widgetTypes"; import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec";
import {IPageWidget} from "app/client/ui/PageWidgetPicker";
import {IconName} from "app/client/ui2018/IconList"; import {IconName} from "app/client/ui2018/IconList";
import {IWidgetType} from "app/common/widgetTypes";
export const widgetTypesMap = new Map<IWidgetType, IWidgetTypeInfo>([ export const widgetTypesMap = new Map<IWidgetType, IWidgetTypeInfo>([
['record', {label: 'Table', icon: 'TypeTable'}], ['record', {label: 'Table', icon: 'TypeTable'}],
@ -22,3 +24,37 @@ export interface IWidgetTypeInfo {
export function getWidgetTypes(sectionType: IWidgetType | null): IWidgetTypeInfo { export function getWidgetTypes(sectionType: IWidgetType | null): IWidgetTypeInfo {
return widgetTypesMap.get(sectionType || 'record') || widgetTypesMap.get('record')!; return widgetTypesMap.get(sectionType || 'record') || widgetTypesMap.get('record')!;
} }
export interface GetTelemetryWidgetTypeOptions {
/** Defaults to `false`. */
isSummary?: boolean;
/** Defaults to `false`. */
isNewTable?: boolean;
}
export function getTelemetryWidgetTypeFromVS(vs: ViewSectionRec) {
return getTelemetryWidgetType(vs.widgetType.peek(), {
isSummary: vs.table.peek().summarySourceTable.peek() !== 0,
});
}
export function getTelemetryWidgetTypeFromPageWidget(widget: IPageWidget) {
return getTelemetryWidgetType(widget.type, {
isNewTable: widget.table === 'New Table',
isSummary: widget.summarize,
});
}
function getTelemetryWidgetType(type: IWidgetType, options: GetTelemetryWidgetTypeOptions = {}) {
let telemetryWidgetType: string | undefined = widgetTypesMap.get(type)?.label;
if (!telemetryWidgetType) { return undefined; }
if (options.isNewTable) {
telemetryWidgetType = 'New ' + telemetryWidgetType;
}
if (options.isSummary) {
telemetryWidgetType += ' (Summary)';
}
return telemetryWidgetType;
}

View File

@ -5,7 +5,6 @@ import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {RuleOwner} from 'app/client/models/RuleOwner'; import {RuleOwner} from 'app/client/models/RuleOwner';
import {Style} from 'app/client/models/Styles'; import {Style} from 'app/client/models/Styles';
import {cssFieldFormula} from 'app/client/ui/FieldConfig'; import {cssFieldFormula} from 'app/client/ui/FieldConfig';
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {withInfoTooltip} from 'app/client/ui/tooltips'; import {withInfoTooltip} from 'app/client/ui/tooltips';
import {textButton} from 'app/client/ui2018/buttons'; import {textButton} from 'app/client/ui2018/buttons';
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect'; import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
@ -78,10 +77,7 @@ export class ConditionalStyle extends Disposable {
dom.on('click', () => this._ruleOwner.addEmptyRule()), dom.on('click', () => this._ruleOwner.addEmptyRule()),
dom.prop('disabled', this._disabled), dom.prop('disabled', this._disabled),
), ),
(this._label === t('Row Style') this._label === t('Row Style') ? 'addRowConditionalStyle' : 'addColumnConditionalStyle'
? GristTooltips.addRowConditionalStyle()
: GristTooltips.addColumnConditionalStyle()
),
), ),
dom.hide(use => use(this._ruleOwner.hasRules)) dom.hide(use => use(this._ruleOwner.hasRules))
), ),

View File

@ -252,7 +252,7 @@ export class FormulaAssistant extends Disposable {
private _logTelemetryEvent(event: TelemetryEvent, includeContext = false, metadata: TelemetryMetadata = {}) { private _logTelemetryEvent(event: TelemetryEvent, includeContext = false, metadata: TelemetryMetadata = {}) {
logTelemetryEvent(event, { logTelemetryEvent(event, {
full: { full: {
docIdDigest: this._gristDoc.docId, docIdDigest: this._gristDoc.docId(),
conversationId: this._chat.conversationId.get(), conversationId: this._chat.conversationId.get(),
...(!includeContext ? {} : {context: { ...(!includeContext ? {} : {context: {
type: 'formula', type: 'formula',

View File

@ -1428,6 +1428,286 @@ export const TelemetryContracts: TelemetryContracts = {
}, },
}, },
}, },
viewedWelcomeTour: {
category: 'Tutorial',
description: 'Triggered when the Grist welcome tour is closed.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
percentComplete: {
description: 'Percentage of tour completion.',
dataType: 'number',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
viewedTip: {
category: 'Tutorial',
description: 'Triggered when a tip is shown.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
tipName: {
description: 'The name of the tip.',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
deletedDoc: {
category: 'DocumentUsage',
description: 'Triggered when a document is deleted.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
addedPage: {
category: 'DocumentUsage',
description: 'Triggered when a page is added.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
deletedPage: {
category: 'DocumentUsage',
description: 'Triggered when a page is deleted.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
addedWidget: {
category: 'WidgetUsage',
description: 'Triggered when a widget is added.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
widgetType: {
description: 'The widget type (e.g. "Form").',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
deletedWidget: {
category: 'WidgetUsage',
description: 'Triggered when a widget is deleted.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
widgetType: {
description: 'The widget type (e.g. "Form").',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
linkedWidget: {
category: 'WidgetUsage',
description: 'Triggered when a widget is linked.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
widgetType: {
description: 'The widget type (e.g. "Form").',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
unlinkedWidget: {
category: 'WidgetUsage',
description: 'Triggered when a widget is unlinked.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
widgetType: {
description: 'The widget type (e.g. "Form").',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
publishedForm: {
category: 'WidgetUsage',
description: 'Triggered when a form is published.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
unpublishedForm: {
category: 'WidgetUsage',
description: 'Triggered when a form is unpublished.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
visitedForm: {
category: 'WidgetUsage',
description: 'Triggered when a published form is visited.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
changedAccessRules: {
category: 'AccessRules',
description: 'Triggered when a change to access rules is saved.',
minimumTelemetryLevel: Level.full,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the doc id.',
dataType: 'string',
},
ruleCount: {
description: 'The number of access rules in the document.',
dataType: 'number',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
},
},
},
}; };
type TelemetryContracts = Record<TelemetryEvent, TelemetryEventContract>; type TelemetryContracts = Record<TelemetryEvent, TelemetryEventContract>;
@ -1484,6 +1764,19 @@ export const TelemetryEvents = StringUnion(
'createdDoc-FileImport', 'createdDoc-FileImport',
'createdDoc-CopyTemplate', 'createdDoc-CopyTemplate',
'createdDoc-CopyDoc', 'createdDoc-CopyDoc',
'viewedWelcomeTour',
'viewedTip',
'deletedDoc',
'addedPage',
'deletedPage',
'addedWidget',
'deletedWidget',
'linkedWidget',
'unlinkedWidget',
'publishedForm',
'unpublishedForm',
'visitedForm',
'changedAccessRules',
); );
export type TelemetryEvent = typeof TelemetryEvents.type; export type TelemetryEvent = typeof TelemetryEvents.type;
@ -1496,7 +1789,9 @@ type TelemetryEventCategory =
| 'SubscriptionPlan' | 'SubscriptionPlan'
| 'DocumentUsage' | 'DocumentUsage'
| 'TeamSite' | 'TeamSite'
| 'ProductVisits'; | 'ProductVisits'
| 'AccessRules'
| 'WidgetUsage';
interface TelemetryEventContract { interface TelemetryEventContract {
description: string; description: string;

View File

@ -233,7 +233,7 @@ export function attachAppEndpoint(options: AttachOptions): void {
const response = await fetch(formUrl, { const response = await fetch(formUrl, {
headers: getTransitiveHeaders(req), headers: getTransitiveHeaders(req),
}); });
if (response.status === 200) { if (response.ok) {
const html = await response.text(); const html = await response.text();
res.send(html); res.send(html);
} else { } else {

View File

@ -1540,6 +1540,13 @@ export class DocWorkerApi {
SUCCESS_URL: redirectUrl, SUCCESS_URL: redirectUrl,
TITLE: `${section.title || tableName || tableId || 'Form'} - Grist` TITLE: `${section.title || tableName || tableId || 'Form'} - Grist`
}); });
this._grist.getTelemetry().logEvent(req, 'visitedForm', {
full: {
docIdDigest: activeDoc.docName,
userId: req.userId,
altSessionId: req.altSessionId,
},
});
res.status(200).send(renderedHtml); res.status(200).send(renderedHtml);
}) })
); );
@ -2026,6 +2033,7 @@ export class DocWorkerApi {
} }
private async _removeDoc(req: Request, res: Response, permanent: boolean) { private async _removeDoc(req: Request, res: Response, permanent: boolean) {
const mreq = req as RequestWithLogin;
const scope = getDocScope(req); const scope = getDocScope(req);
const docId = getDocId(req); const docId = getDocId(req);
if (permanent) { if (permanent) {
@ -2045,6 +2053,13 @@ export class DocWorkerApi {
// Permanently delete from database. // Permanently delete from database.
const query = await this._dbManager.deleteDocument(scope); const query = await this._dbManager.deleteDocument(scope);
this._dbManager.checkQueryResult(query); this._dbManager.checkQueryResult(query);
this._grist.getTelemetry().logEvent(mreq, 'deletedDoc', {
full: {
docIdDigest: docId,
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
});
await sendReply(req, res, query); await sendReply(req, res, query);
} else { } else {
await this._dbManager.softDeleteDocument(scope); await this._dbManager.softDeleteDocument(scope);