diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 4d89ad6a..1026014b 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -24,6 +24,7 @@ import {DocPluginManager} from 'app/client/lib/DocPluginManager'; import {ImportSourceElement} from 'app/client/lib/ImportSourceElement'; import {makeT} from 'app/client/lib/localization'; import {createSessionObs} from 'app/client/lib/sessionObs'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {setTestState} from 'app/client/lib/testState'; import {selectFiles} from 'app/client/lib/uploads'; import {AppModel, reportError} from 'app/client/models/AppModel'; @@ -62,7 +63,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {isSchemaAction, UserAction} from 'app/common/DocActions'; import {OpenLocalDocResult} from 'app/common/DocListAPI'; import {isList, isListType, isRefListType, RecalcWhen} from 'app/common/gristTypes'; -import {HashLink, IDocPage, isViewDocPage, SpecialDocPage, ViewDocPage} from 'app/common/gristUrls'; +import {HashLink, IDocPage, isViewDocPage, parseUrlId, SpecialDocPage, ViewDocPage} from 'app/common/gristUrls'; import {undef, waitObs} from 'app/common/gutil'; import {LocalPlugin} from "app/common/plugin"; import {StringUnion} from 'app/common/StringUnion'; @@ -400,6 +401,16 @@ export class GristDoc extends DisposableWithEvents { && markAsSeen(this._seenDocTours, this.docId()) ); await startDocTour(this.docData, this.docComm, onFinishCB); + if (this.docPageModel.isTemplate.get()) { + const doc = this.docPageModel.currentDoc.get(); + if (!doc) { return; } + + logTelemetryEvent('openedTemplateTour', { + full: { + templateId: parseUrlId(doc.urlId || doc.id).trunkId, + }, + }); + } } else { startWelcomeTour(() => this._showGristTour.set(false)); } diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 9b4cf3ac..689b23c2 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -27,6 +27,7 @@ import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app import {Holder, Observable, subscribe} from 'grainjs'; import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs'; import {makeT} from 'app/client/lib/localization'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; // tslint:disable:no-console @@ -365,6 +366,14 @@ It also disables formulas. [{{error}}]", {error: err.message}) await this.updateUrlNoReload(doc.urlId, doc.openMode); } + if (doc.isTemplate) { + logTelemetryEvent('openedTemplate', { + full: { + templateId: parseUrlId(doc.urlId || doc.id).trunkId, + }, + }); + } + this.currentDoc.set(doc); } diff --git a/app/client/ui/DocTutorial.ts b/app/client/ui/DocTutorial.ts index 15b7368e..d2c170df 100644 --- a/app/client/ui/DocTutorial.ts +++ b/app/client/ui/DocTutorial.ts @@ -59,6 +59,8 @@ export class DocTutorial extends FloatingPopup { if (tableData) { this.autoDispose(tableData.tableActionEmitter.addListener(() => this._reloadSlides())); } + + this._logTelemetryEvent('tutorialOpened'); } protected _buildTitle() { @@ -158,6 +160,24 @@ export class DocTutorial extends FloatingPopup { ]; } + private _logTelemetryEvent(event: 'tutorialOpened' | 'tutorialProgressChanged') { + const currentSlideIndex = this._currentSlideIndex.get(); + const numSlides = this._slides.get()?.length; + let percentComplete: number | undefined = undefined; + if (numSlides !== undefined && numSlides > 0) { + percentComplete = Math.floor(((currentSlideIndex + 1) / numSlides) * 100); + } + logTelemetryEvent(event, { + full: { + tutorialForkIdDigest: this._currentFork?.id, + tutorialTrunkIdDigest: this._currentFork?.trunkId, + lastSlideIndex: currentSlideIndex, + numSlides, + percentComplete, + }, + }); + } + private async _loadSlides() { const tableId = 'GristDocTutorial'; if (!this._docData.getTable(tableId)) { @@ -234,7 +254,6 @@ export class DocTutorial extends FloatingPopup { private async _saveCurrentSlidePosition() { const currentOptions = this._currentDoc?.options ?? {}; const currentSlideIndex = this._currentSlideIndex.get(); - const numSlides = this._slides.get()?.length; await this._appModel.api.updateDoc(this._docId, { options: { ...currentOptions, @@ -243,20 +262,7 @@ export class DocTutorial extends FloatingPopup { } } }); - - let percentComplete: number | undefined = undefined; - if (numSlides !== undefined && numSlides > 0) { - percentComplete = Math.floor(((currentSlideIndex + 1) / numSlides) * 100); - } - logTelemetryEvent('tutorialProgressChanged', { - full: { - tutorialForkIdDigest: this._currentFork?.id, - tutorialTrunkIdDigest: this._currentFork?.trunkId, - lastSlideIndex: currentSlideIndex, - numSlides, - percentComplete, - }, - }); + this._logTelemetryEvent('tutorialProgressChanged'); } private async _changeSlide(slideIndex: number) { diff --git a/app/client/ui/WelcomeCoachingCall.ts b/app/client/ui/WelcomeCoachingCall.ts index 51077907..94b8bd3c 100644 --- a/app/client/ui/WelcomeCoachingCall.ts +++ b/app/client/ui/WelcomeCoachingCall.ts @@ -1,3 +1,4 @@ +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {AppModel} from 'app/client/models/AppModel'; import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; import {testId, theme, vars} from 'app/client/ui2018/cssVars'; @@ -93,7 +94,10 @@ export function showWelcomeCoachingCall(triggerElement: Element, appModel: AppMo cssPopupButtons( bigPrimaryButtonLink( 'Schedule Call', - dom.on('click', () => dismissPopup(false)), + dom.on('click', () => { + dismissPopup(false); + logTelemetryEvent('clickedScheduleCoachingCall'); + }), { href: FREE_COACHING_CALL_URL, target: '_blank', diff --git a/app/client/widgets/FormulaAssistant.ts b/app/client/widgets/FormulaAssistant.ts index 3da25c2e..2a8d50df 100644 --- a/app/client/widgets/FormulaAssistant.ts +++ b/app/client/widgets/FormulaAssistant.ts @@ -172,9 +172,16 @@ export class FormulaAssistant extends Disposable { this._triggerFinalize = bundleInfo.triggerFinalize; this.onDispose(() => { if (this._hasExpandedOnce) { + const suggestionApplied = this._chat.conversationSuggestedFormulas.get() + .includes(this._options.column.formula.peek()); + if (suggestionApplied) { + this._logTelemetryEvent('assistantApplySuggestion', false, { + conversationLength: this._chat.conversationLength.get(), + conversationHistoryLength: this._chat.conversationHistoryLength.get(), + }); + } this._logTelemetryEvent('assistantClose', false, { - suggestionApplied: this._chat.conversationSuggestedFormulas.get() - .includes(this._options.column.formula.peek()), + suggestionApplied, conversationLength: this._chat.conversationLength.get(), conversationHistoryLength: this._chat.conversationHistoryLength.get(), }); @@ -400,7 +407,9 @@ export class FormulaAssistant extends Disposable { */ private _cancel() { if (this._hasExpandedOnce) { - this._logTelemetryEvent('assistantCancel', true); + this._logTelemetryEvent('assistantCancel', true, { + conversationLength: this._chat.conversationLength.get(), + }); } this._action = 'cancel'; this._triggerFinalize(); diff --git a/app/common/Telemetry.ts b/app/common/Telemetry.ts index e5a0164e..77a1cb58 100644 --- a/app/common/Telemetry.ts +++ b/app/common/Telemetry.ts @@ -42,6 +42,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, assistantOpen: { + category: 'AIAssistant', description: 'Triggered when the AI Assistant is first opened.', minimumTelemetryLevel: Level.full, retentionPeriod: 'short', @@ -69,6 +70,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, assistantSend: { + category: 'AIAssistant', description: 'Triggered when a message is sent to the AI Assistant.', minimumTelemetryLevel: Level.full, retentionPeriod: 'short', @@ -112,7 +114,8 @@ export const TelemetryContracts: TelemetryContracts = { }, }, assistantReceive: { - description: 'Triggered when a message is received from the AI Assistant is received.', + category: 'AIAssistant', + description: 'Triggered when a message is received from the AI Assistant.', minimumTelemetryLevel: Level.full, retentionPeriod: 'short', metadataContracts: { @@ -159,6 +162,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, assistantSave: { + category: 'AIAssistant', description: 'Triggered when changes in the expanded formula editor are saved after the AI Assistant ' + 'was opened.', minimumTelemetryLevel: Level.full, @@ -195,6 +199,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, assistantCancel: { + category: 'AIAssistant', description: 'Triggered when changes in the expanded formula editor are discarded after the AI Assistant ' + 'was opened.', minimumTelemetryLevel: Level.full, @@ -208,6 +213,10 @@ export const TelemetryContracts: TelemetryContracts = { description: 'A random identifier for the current conversation with the assistant.', dataType: 'string', }, + conversationLength: { + description: 'The number of messages sent and received since opening the AI Assistant.', + dataType: 'number', + }, context: { description: 'The type of assistant (e.g. "formula"), table id, and column id.', dataType: 'object', @@ -222,7 +231,41 @@ export const TelemetryContracts: TelemetryContracts = { }, }, }, + assistantApplySuggestion: { + category: 'AIAssistant', + description: 'Triggered when a suggested formula from one of the received messages was applied and saved.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + conversationId: { + description: 'A random identifier for the current conversation with the assistant.', + dataType: 'string', + }, + conversationLength: { + description: 'The number of messages sent and received since opening the AI Assistant.', + dataType: 'number', + }, + conversationHistoryLength: { + description: "The number of messages in the conversation's history. May be less than conversationLength " + + "if the conversation history was cleared in the same session.", + 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', + }, + }, + }, assistantClearConversation: { + category: 'AIAssistant', description: 'Triggered when a conversation in the AI Assistant is cleared.', minimumTelemetryLevel: Level.full, retentionPeriod: 'short', @@ -250,6 +293,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, assistantClose: { + category: 'AIAssistant', description: 'Triggered when a formula is saved or discarded after the AI Assistant was opened.', minimumTelemetryLevel: Level.full, retentionPeriod: 'indefinitely', @@ -286,6 +330,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, beaconOpen: { + category: 'HelpCenter', description: 'Triggered when HelpScout Beacon is opened.', minimumTelemetryLevel: Level.full, retentionPeriod: 'indefinitely', @@ -301,6 +346,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, beaconArticleViewed: { + category: 'HelpCenter', description: 'Triggered when an article is opened in HelpScout Beacon.', minimumTelemetryLevel: Level.full, retentionPeriod: 'indefinitely', @@ -320,6 +366,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, beaconEmailSent: { + category: 'HelpCenter', description: 'Triggered when an email is sent in HelpScout Beacon.', minimumTelemetryLevel: Level.full, retentionPeriod: 'indefinitely', @@ -335,6 +382,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, beaconSearch: { + category: 'HelpCenter', description: 'Triggered when a search is made in HelpScout Beacon.', minimumTelemetryLevel: Level.full, retentionPeriod: 'indefinitely', @@ -806,7 +854,44 @@ export const TelemetryContracts: TelemetryContracts = { }, }, }, + tutorialOpened: { + category: 'Tutorial', + description: 'Triggered when a tutorial is opened.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + tutorialForkIdDigest: { + description: 'A hash of the tutorial fork id.', + dataType: 'string', + }, + tutorialTrunkIdDigest: { + description: 'A hash of the tutorial trunk id.', + dataType: 'string', + }, + lastSlideIndex: { + description: 'The 0-based index of the last tutorial slide the user had open.', + dataType: 'number', + }, + numSlides: { + description: 'The total number of slides in the tutorial.', + dataType: 'number', + }, + percentComplete: { + description: 'Percentage of tutorial 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', + }, + }, + }, tutorialProgressChanged: { + category: 'Tutorial', description: 'Triggered on changes to tutorial progress.', minimumTelemetryLevel: Level.full, retentionPeriod: 'indefinitely', @@ -842,6 +927,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, tutorialRestarted: { + category: 'Tutorial', description: 'Triggered when a tutorial is restarted.', minimumTelemetryLevel: Level.full, retentionPeriod: 'indefinitely', @@ -881,6 +967,7 @@ export const TelemetryContracts: TelemetryContracts = { }, }, watchedVideoTour: { + category: 'Welcome', description: 'Triggered when the video tour is closed.', minimumTelemetryLevel: Level.limited, retentionPeriod: 'indefinitely', @@ -901,17 +988,14 @@ export const TelemetryContracts: TelemetryContracts = { }, }, }, - welcomeQuestionsSubmitted: { - description: 'Triggered when the welcome questionnaire is submitted.', + answeredUseCaseQuestion: { + category: 'Welcome', + description: 'Triggered for each selected use case in the welcome questionnaire.', minimumTelemetryLevel: Level.full, retentionPeriod: 'indefinitely', metadataContracts: { - useCases: { - description: 'The selected use cases.', - dataType: 'string[]', - }, - useOther: { - description: 'The value of the Other use case.', + useCase: { + description: 'The selected use case. If "Other", the response is also included.', dataType: 'string', }, userId: { @@ -920,6 +1004,164 @@ export const TelemetryContracts: TelemetryContracts = { }, }, }, + clickedScheduleCoachingCall: { + category: 'Welcome', + description: 'Triggered when the link to schedule a coaching call is clicked.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + 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', + }, + }, + }, + deletedAccount: { + category: 'SubscriptionPlan', + description: 'Triggered when an account is deleted.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + }, + createdSite: { + category: 'TeamSite', + description: 'Triggered when a site is created.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + siteId: { + description: 'The id of the site.', + dataType: 'number', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + }, + }, + deletedSite: { + category: 'TeamSite', + description: 'Triggered when a site is deleted.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + siteId: { + description: 'The id of the site.', + dataType: 'number', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + }, + }, + openedTemplate: { + category: 'TemplateUsage', + description: 'Triggered when a template is opened.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + templateId: { + description: 'The document id of the template.', + 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', + }, + }, + }, + openedTemplateTour: { + category: 'TemplateUsage', + description: 'Triggered when a document tour for a template is opened.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + templateId: { + description: 'The document id of the template.', + 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', + }, + }, + }, + subscribedToPlan: { + category: 'SubscriptionPlan', + description: 'Triggered on subscription to a plan.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + planName: { + description: 'The name of the plan.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + }, + }, + cancelledPlan: { + category: 'SubscriptionPlan', + description: 'Triggered on cancellation of a plan.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + planName: { + description: 'The name of the plan.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + }, + }, + createdWorkspace: { + category: 'DocumentUsage', + description: 'Triggered when a workspace is created.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + workspaceId: { + description: 'The id of the workspace.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + }, + }, + deletedWorkspace: { + category: 'DocumentUsage', + description: 'Triggered when a workspace is deleted.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + workspaceId: { + description: 'The id of the workspace.', + dataType: 'number', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + }, + }, }; type TelemetryContracts = Record; @@ -931,6 +1173,7 @@ export const TelemetryEvents = StringUnion( 'assistantReceive', 'assistantSave', 'assistantCancel', + 'assistantApplySuggestion', 'assistantClearConversation', 'assistantClose', 'beaconOpen', @@ -947,17 +1190,39 @@ export const TelemetryEvents = StringUnion( 'signupVerified', 'siteMembership', 'siteUsage', + 'tutorialOpened', 'tutorialProgressChanged', 'tutorialRestarted', 'watchedVideoTour', - 'welcomeQuestionsSubmitted', + 'answeredUseCaseQuestion', + 'clickedScheduleCoachingCall', + 'deletedAccount', + 'createdSite', + 'deletedSite', + 'openedTemplate', + 'openedTemplateTour', + 'subscribedToPlan', + 'cancelledPlan', + 'createdWorkspace', + 'deletedWorkspace', ); export type TelemetryEvent = typeof TelemetryEvents.type; +type TelemetryEventCategory = + | 'AIAssistant' + | 'HelpCenter' + | 'TemplateUsage' + | 'Tutorial' + | 'Welcome' + | 'SubscriptionPlan' + | 'DocumentUsage' + | 'TeamSite'; + interface TelemetryEventContract { description: string; minimumTelemetryLevel: Level; retentionPeriod: TelemetryRetentionPeriod; + category?: TelemetryEventCategory; metadataContracts?: Record; } diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 26f91e2d..260d1971 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -189,8 +189,15 @@ export class ApiServer { // Body params: name // Create a new workspace owned by the specific organization. this._app.post('/api/orgs/:oid/workspaces', expressWrap(async (req, res) => { + const mreq = req as RequestWithLogin; const org = getOrgKey(req); const query = await this._dbManager.addWorkspace(getScope(req), org, req.body); + this._gristServer.getTelemetry().logEvent(mreq, 'createdWorkspace', { + full: { + workspaceId: query.data, + userId: mreq.userId, + }, + }); return sendReply(req, res, query); })); @@ -206,8 +213,15 @@ export class ApiServer { // DELETE /api/workspaces/:wid // Delete the specified workspace and all included docs. this._app.delete('/api/workspaces/:wid', expressWrap(async (req, res) => { + const mreq = req as RequestWithLogin; const wsId = integerParam(req.params.wid, 'wid'); const query = await this._dbManager.deleteWorkspace(getScope(req), wsId); + this._gristServer.getTelemetry().logEvent(mreq, 'deletedWorkspace', { + full: { + workspaceId: wsId, + userId: mreq.userId, + }, + }); return sendReply(req, res, query); })); @@ -217,7 +231,14 @@ export class ApiServer { this._app.post('/api/workspaces/:wid/remove', expressWrap(async (req, res) => { const wsId = integerParam(req.params.wid, 'wid'); if (isParameterOn(req.query.permanent)) { + const mreq = req as RequestWithLogin; const query = await this._dbManager.deleteWorkspace(getScope(req), wsId); + this._gristServer.getTelemetry().logEvent(mreq, 'deletedWorkspace', { + full: { + workspaceId: wsId, + userId: mreq.userId, + }, + }); return sendReply(req, res, query); } else { await this._dbManager.softDeleteWorkspace(getScope(req), wsId); @@ -240,7 +261,7 @@ export class ApiServer { const mreq = req as RequestWithLogin; const wsId = integerParam(req.params.wid, 'wid'); const query = await this._dbManager.addDocument(getScope(req), wsId, req.body); - this._gristServer.getTelemetry().logEvent('documentCreated', { + this._gristServer.getTelemetry().logEvent(mreq, 'documentCreated', { limited: { docIdDigest: query.data!, sourceDocIdDigest: undefined, @@ -252,8 +273,7 @@ export class ApiServer { userId: mreq.userId, altSessionId: mreq.altSessionId, }, - }) - .catch(e => log.error('failed to log telemetry event documentCreated', e)); + }); return sendReply(req, res, query); })); diff --git a/app/gen-server/lib/Housekeeper.ts b/app/gen-server/lib/Housekeeper.ts index 5784976b..18c0ada9 100644 --- a/app/gen-server/lib/Housekeeper.ts +++ b/app/gen-server/lib/Housekeeper.ts @@ -178,7 +178,7 @@ export class Housekeeper { await this._dbManager.connection.transaction('READ UNCOMMITTED', async (manager) => { const usageSummaries = await this._getOrgUsageSummaries(manager); for (const summary of usageSummaries) { - this._telemetry.logEvent('siteUsage', { + this._telemetry.logEvent(null, 'siteUsage', { limited: { siteId: summary.site_id, siteType: summary.site_type, @@ -192,13 +192,12 @@ export class Housekeeper { full: { stripePlanId: summary.stripe_plan_id, }, - }) - .catch(e => log.error('failed to log telemetry event siteUsage', e)); + }); } const membershipSummaries = await this._getOrgMembershipSummaries(manager); for (const summary of membershipSummaries) { - this._telemetry.logEvent('siteMembership', { + this._telemetry.logEvent(null, 'siteMembership', { limited: { siteId: summary.site_id, siteType: summary.site_type, @@ -206,8 +205,7 @@ export class Housekeeper { numEditors: Number(summary.num_editors), numViewers: Number(summary.num_viewers), }, - }) - .catch(e => log.error('failed to log telemetry event siteMembership', e)); + }); } }); } diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 96bc80ea..bd24e94f 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -89,7 +89,6 @@ import {Authorizer, RequestWithLogin} from 'app/server/lib/Authorizer'; import {checksumFile} from 'app/server/lib/checksumFile'; import {Client} from 'app/server/lib/Client'; import {DEFAULT_CACHE_TTL, DocManager} from 'app/server/lib/DocManager'; -import {getTemplateOrg} from 'app/server/lib/gristSettings'; import {ICreateActiveDocOptions} from 'app/server/lib/ICreate'; import {makeForkIds} from 'app/server/lib/idUtils'; import {GRIST_DOC_SQL, GRIST_DOC_WITH_TABLE1_SQL} from 'app/server/lib/initialDocSql'; @@ -1420,9 +1419,7 @@ export class ActiveDoc extends EventEmitter { await dbManager.forkDoc(userId, doc, forkIds.forkId); - // TODO: Remove the right side once all template docs have their type set to "template". - const isTemplate = doc.type === 'template' || - (doc.workspace.org.domain === getTemplateOrg() && doc.type !== 'tutorial'); + const isTemplate = doc.type === 'template'; this.logTelemetryEvent(docSession, 'documentForked', { limited: { forkIdDigest: forkIds.forkId, @@ -1827,11 +1824,10 @@ export class ActiveDoc extends EventEmitter { event: TelemetryEvent, metadata?: TelemetryMetadataByLevel ) { - this._docManager.gristServer.getTelemetry().logEvent(event, merge( + this._docManager.gristServer.getTelemetry().logEvent(docSession, event, merge( this._getTelemetryMeta(docSession), metadata, - )) - .catch(e => this._log.error(docSession, `failed to log telemetry event ${event}`, e)); + )); } /** diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index 5af03097..eb6cc661 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -19,7 +19,6 @@ import {customizeDocWorkerUrl, getWorker, useWorkerPool} from 'app/server/lib/Do import {expressWrap} from 'app/server/lib/expressWrap'; import {DocTemplate, GristServer} from 'app/server/lib/GristServer'; import {getCookieDomain} from 'app/server/lib/gristSessions'; -import {getTemplateOrg} from 'app/server/lib/gristSettings'; import {getAssignmentId} from 'app/server/lib/idUtils'; import log from 'app/server/lib/log'; import {addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils'; @@ -165,11 +164,9 @@ export function attachAppEndpoint(options: AttachOptions): void { const isPublic = ((doc as unknown) as APIDocument).public ?? false; const isSnapshot = Boolean(parseUrlId(urlId).snapshotId); - // TODO: Remove the right side once all template docs have their type set to "template". - const isTemplate = doc.type === 'template' || - (doc.workspace.org.domain === getTemplateOrg() && doc.type !== 'tutorial'); + const isTemplate = doc.type === 'template'; if (isPublic || isTemplate) { - gristServer.getTelemetry().logEvent('documentOpened', { + gristServer.getTelemetry().logEvent(mreq, 'documentOpened', { limited: { docIdDigest: docId, access: doc.access, @@ -184,8 +181,7 @@ export function attachAppEndpoint(options: AttachOptions): void { userId: mreq.userId, altSessionId: mreq.altSessionId, }, - }) - .catch(e => log.error('failed to log telemetry event documentOpened', e)); + }); } if (isTemplate) { diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 033a56d5..f13f5657 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -397,14 +397,13 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer }; log.rawDebug(`Auth[${meta.method}]: ${meta.host} ${meta.path}`, meta); if (hasApiKey) { - options.gristServer.getTelemetry().logEvent('apiUsage', { + options.gristServer.getTelemetry().logEvent(mreq, 'apiUsage', { full: { method: mreq.method, userId: mreq.userId, userAgent: mreq.headers['user-agent'], }, - }) - .catch(e => log.error('failed to log telemetry event apiUsage', e)); + }); } return next(); diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 03fed713..a0624204 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -1044,7 +1044,7 @@ export class DocWorkerApi { const tutorialTrunkId = options.sourceDocId; await this._dbManager.connection.transaction(async (manager) => { // Fetch the tutorial trunk doc so we can replace the tutorial doc's name. - const tutorialTrunk = await this._dbManager.getRawDocById(tutorialTrunkId, manager); + const tutorialTrunk = await this._dbManager.getDoc({...scope, urlId: tutorialTrunkId}, manager); await this._dbManager.updateDocument( scope, { @@ -1152,7 +1152,7 @@ export class DocWorkerApi { const userId = getUserId(req); const wsId = integerParam(req.params.wid, 'wid'); const uploadId = integerParam(req.body.uploadId, 'uploadId'); - const result = await this._docManager.importDocToWorkspace({ + const result = await this._docManager.importDocToWorkspace(mreq, { userId, uploadId, workspaceId: wsId, @@ -1284,7 +1284,7 @@ export class DocWorkerApi { asTemplate: optBooleanParam(parameters.asTemplate, 'asTemplate'), }); } else if (uploadId !== undefined) { - const result = await this._docManager.importDocToWorkspace({ + const result = await this._docManager.importDocToWorkspace(mreq, { userId, uploadId, documentName: optStringParam(parameters.documentName, 'documentName'), @@ -1343,7 +1343,7 @@ export class DocWorkerApi { } // Then, import the copy to the workspace. - const result = await this._docManager.importDocToWorkspace({ + const result = await this._docManager.importDocToWorkspace(mreq, { userId, uploadId: uploadResult.uploadId, documentName, @@ -1419,13 +1419,12 @@ export class DocWorkerApi { private _logDocumentCreatedTelemetryEvent(req: Request, metadata: TelemetryMetadataByLevel) { const mreq = req as RequestWithLogin; - this._grist.getTelemetry().logEvent('documentCreated', _.merge({ + this._grist.getTelemetry().logEvent(mreq, 'documentCreated', _.merge({ full: { userId: mreq.userId, altSessionId: mreq.altSessionId, }, - }, metadata)) - .catch(e => log.error('failed to log telemetry event documentCreated', e)); + }, metadata)); } /** diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts index 123477c9..766a735c 100644 --- a/app/server/lib/DocManager.ts +++ b/app/server/lib/DocManager.ts @@ -15,7 +15,8 @@ import {tbind} from 'app/common/tbind'; import {TelemetryMetadataByLevel} from 'app/common/Telemetry'; import {NEW_DOCUMENT_CODE} from 'app/common/UserAPI'; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; -import {assertAccess, Authorizer, DocAuthorizer, DummyAuthorizer, isSingleUserMode} from 'app/server/lib/Authorizer'; +import {assertAccess, Authorizer, DocAuthorizer, DummyAuthorizer, isSingleUserMode, + RequestWithLogin} from 'app/server/lib/Authorizer'; import {Client} from 'app/server/lib/Client'; import { getDocSessionCachedDoc, @@ -198,7 +199,7 @@ export class DocManager extends EventEmitter { * * Cleans up `uploadId` and returns creation info about the imported doc. */ - public async importDocToWorkspace(options: { + public async importDocToWorkspace(mreq: RequestWithLogin, options: { userId: number, uploadId: number, documentName?: string, @@ -232,14 +233,13 @@ export class DocManager extends EventEmitter { userId, }); - this.gristServer.getTelemetry().logEvent('documentCreated', merge({ + this.gristServer.getTelemetry().logEvent(mreq, 'documentCreated', merge({ limited: { docIdDigest: docCreationInfo.id, fileType: uploadInfo.files[0].ext.trim().slice(1), isSaved: workspaceId !== undefined, }, - }, telemetryMetadata)) - .catch(e => log.error('failed to log telemetry event documentCreated', e)); + }, telemetryMetadata)); return docCreationInfo; // The imported document is associated with the worker that did the import. diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 659b49f2..0e937fe5 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1309,6 +1309,7 @@ export class FlexServer implements GristServer { // to other (not public) team sites. const doom = await createDoom(req); await doom.deleteUser(userId); + this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedAccount'); return resp.status(200).json(true); })); @@ -1341,6 +1342,14 @@ export class FlexServer implements GristServer { // Reuse Doom cli tool for org deletion. Note, this removes everything as a super user. const doom = await createDoom(req); await doom.deleteOrg(org.id); + + this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedSite', { + full: { + siteId: org.id, + userId: mreq.userId, + }, + }); + return resp.status(200).send(); })); } @@ -1442,14 +1451,15 @@ export class FlexServer implements GristServer { // If we failed to record, at least log the data, so we could potentially recover it. log.rawWarn(`Failed to record new user info: ${e.message}`, {newUserQuestions: row}); }); - this.getTelemetry().logEvent('welcomeQuestionsSubmitted', { - full: { - userId, - useCases, - useOther, - }, - }) - .catch(e => log.error('failed to log telemetry event welcomeQuestionsSubmitted', e)); + const nonOtherUseCases = useCases.filter(useCase => useCase !== 'Other'); + for (const useCase of [...nonOtherUseCases, ...(useOther ? [`Other - ${useOther}`] : [])]) { + this.getTelemetry().logEvent(req as RequestWithLogin, 'answeredUseCaseQuestion', { + full: { + userId, + useCase, + }, + }); + } resp.status(200).send(); }), jsonErrorHandler); // Add a final error handler that reports errors as JSON. diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index 3a5ab13d..65e3cade 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -154,7 +154,8 @@ export function createDummyTelemetry(): ITelemetry { addEndpoints() { /* do nothing */ }, addPages() { /* do nothing */ }, start() { return Promise.resolve(); }, - logEvent() { return Promise.resolve(); }, + logEvent() { /* do nothing */ }, + logEventAsync() { return Promise.resolve(); }, getTelemetryConfig() { return undefined; }, fetchTelemetryPrefs() { return Promise.resolve(); }, }; diff --git a/app/server/lib/ProcessMonitor.ts b/app/server/lib/ProcessMonitor.ts index e830e9d9..a2879ffe 100644 --- a/app/server/lib/ProcessMonitor.ts +++ b/app/server/lib/ProcessMonitor.ts @@ -1,4 +1,3 @@ -import log from 'app/server/lib/log'; import { ITelemetry } from 'app/server/lib/Telemetry'; const MONITOR_PERIOD_MS = 5_000; // take a look at memory usage this often @@ -67,15 +66,14 @@ function monitor(telemetry: ITelemetry) { Math.abs(heapUsed - _lastReportedHeapUsed) > _lastReportedHeapUsed * MEMORY_DELTA_FRACTION || Math.abs(cpuAverage - _lastReportedCpuAverage) > CPU_DELTA_FRACTION ) { - telemetry.logEvent('processMonitor', { + telemetry.logEvent(null, 'processMonitor', { full: { heapUsedMB: Math.round(memoryUsage.heapUsed/1024/1024), heapTotalMB: Math.round(memoryUsage.heapTotal/1024/1024), cpuAverage: Math.round(cpuAverage * 100) / 100, intervalMs, }, - }) - .catch(e => log.error('failed to log telemetry event processMonitor', e)); + }); _lastReportedHeapUsed = heapUsed; _lastReportedCpuAverage = cpuAverage; _lastReportTime = now; diff --git a/app/server/lib/Telemetry.ts b/app/server/lib/Telemetry.ts index 4c7cb2d2..17c23bc8 100644 --- a/app/server/lib/Telemetry.ts +++ b/app/server/lib/Telemetry.ts @@ -19,22 +19,35 @@ import {Activation} from 'app/gen-server/entity/Activation'; import {Activations} from 'app/gen-server/lib/Activations'; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {RequestWithLogin} from 'app/server/lib/Authorizer'; +import {getDocSessionUser, OptDocSession} from 'app/server/lib/DocSession'; import {expressWrap} from 'app/server/lib/expressWrap'; import {GristServer} from 'app/server/lib/GristServer'; import {hashId} from 'app/server/lib/hashingUtils'; import {LogMethods} from 'app/server/lib/LogMethods'; import {stringParam} from 'app/server/lib/requestUtils'; +import {getLogMetaFromDocSession} from 'app/server/lib/serverUtils'; import * as express from 'express'; import fetch from 'node-fetch'; import merge = require('lodash/merge'); import pickBy = require('lodash/pickBy'); +type RequestOrSession = RequestWithLogin | OptDocSession | null; + export interface ITelemetry { start(): Promise; - logEvent(name: TelemetryEvent, metadata?: TelemetryMetadataByLevel): Promise; + logEvent( + requestOrSession: RequestOrSession, + name: TelemetryEvent, + metadata?: TelemetryMetadataByLevel + ): void; + logEventAsync( + requestOrSession: RequestOrSession, + name: TelemetryEvent, + metadata?: TelemetryMetadataByLevel + ): Promise; addEndpoints(app: express.Express): void; addPages(app: express.Express, middleware: express.RequestHandler[]): void; - getTelemetryConfig(): TelemetryConfig | undefined; + getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined; fetchTelemetryPrefs(): Promise; } @@ -51,7 +64,8 @@ export class Telemetry implements ITelemetry { private readonly _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL || 'https://telemetry.getgrist.com/api/telemetry'; private _numPendingForwardEventRequests = 0; - private readonly _logger = new LogMethods('Telemetry ', () => ({})); + private readonly _logger = new LogMethods('Telemetry ', (requestOrSession: RequestOrSession | undefined) => + this._getLogMeta(requestOrSession)); private readonly _telemetryLogger = new LogMethods('Telemetry ', (eventType) => ({ eventType, })); @@ -105,34 +119,27 @@ export class Telemetry implements ITelemetry { * }); * ``` */ - public async logEvent( + public async logEventAsync( + requestOrSession: RequestOrSession, event: TelemetryEvent, metadata?: TelemetryMetadataByLevel ) { - if (!this._checkTelemetryEvent) { - this._logger.error(undefined, 'logEvent called but telemetry event checker is undefined'); - return; - } + await this._checkAndLogEvent(requestOrSession, event, metadata); + } - const prefs = this._telemetryPrefs; - if (!prefs) { - this._logger.error(undefined, 'logEvent called but telemetry preferences are undefined'); - return; - } - - const {telemetryLevel} = prefs; - if (TelemetryContracts[event] && TelemetryContracts[event].minimumTelemetryLevel > Level[telemetryLevel.value]) { - return; - } - - metadata = filterMetadata(metadata, telemetryLevel.value); - this._checkTelemetryEvent(event, metadata); - - if (this._shouldForwardTelemetryEvents) { - await this._forwardEvent(event, metadata); - } else { - this._logEvent(event, metadata); - } + /** + * Non-async variant of `logEventAsync`. + * + * Convenient for fire-and-forget usage. + */ + public logEvent( + requestOrSession: RequestOrSession, + event: TelemetryEvent, + metadata?: TelemetryMetadataByLevel + ) { + this.logEventAsync(requestOrSession, event, metadata).catch((e) => { + this._logger.error(requestOrSession, `failed to log telemetry event ${event}`, e); + }); } public addEndpoints(app: express.Application) { @@ -151,7 +158,7 @@ export class Telemetry implements ITelemetry { app.post('/api/telemetry', expressWrap(async (req, resp) => { const mreq = req as RequestWithLogin; const event = stringParam(req.body.event, 'event', {allowed: TelemetryEvents.values}) as TelemetryEvent; - if ('eventSource' in req.body.metadata) { + if ('eventSource' in (req.body.metadata ?? {})) { this._telemetryLogger.rawLog('info', getEventType(event), event, { ...(removeNullishKeys(req.body.metadata)), eventName: event, @@ -159,7 +166,7 @@ export class Telemetry implements ITelemetry { } else { try { this._assertTelemetryIsReady(); - await this.logEvent(event, merge( + await this._checkAndLogEvent(mreq, event, merge( { full: { userId: mreq.userId, @@ -169,7 +176,7 @@ export class Telemetry implements ITelemetry { req.body.metadata, )); } catch (e) { - this._logger.error(undefined, `failed to log telemetry event ${event}`, e); + this._logger.error(mreq, `failed to log telemetry event ${event}`, e); throw new ApiError(`Telemetry failed to log telemetry event ${event}`, 500); } } @@ -186,10 +193,10 @@ export class Telemetry implements ITelemetry { } } - public getTelemetryConfig(): TelemetryConfig | undefined { + public getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined { const prefs = this._telemetryPrefs; if (!prefs) { - this._logger.error(undefined, 'getTelemetryConfig called but telemetry preferences are undefined'); + this._logger.error(requestOrSession, 'getTelemetryConfig called but telemetry preferences are undefined'); return undefined; } @@ -208,41 +215,88 @@ export class Telemetry implements ITelemetry { this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryPrefs.telemetryLevel.value); } + private async _checkAndLogEvent( + requestOrSession: RequestOrSession, + event: TelemetryEvent, + metadata?: TelemetryMetadataByLevel + ) { + if (!this._checkTelemetryEvent) { + this._logger.error(null, 'logEvent called but telemetry event checker is undefined'); + return; + } + + const prefs = this._telemetryPrefs; + if (!prefs) { + this._logger.error(null, 'logEvent called but telemetry preferences are undefined'); + return; + } + + const {telemetryLevel} = prefs; + if (TelemetryContracts[event] && TelemetryContracts[event].minimumTelemetryLevel > Level[telemetryLevel.value]) { + return; + } + + metadata = filterMetadata(metadata, telemetryLevel.value); + this._checkTelemetryEvent(event, metadata); + + if (this._shouldForwardTelemetryEvents) { + await this._forwardEvent(requestOrSession, event, metadata); + } else { + this._logEvent(requestOrSession, event, metadata); + } + } + private _logEvent( + requestOrSession: RequestOrSession, event: TelemetryEvent, metadata?: TelemetryMetadata ) { + let isInternalUser: boolean | undefined; + if (requestOrSession) { + const email = ('get' in requestOrSession) + ? requestOrSession.user?.loginEmail + : getDocSessionUser(requestOrSession)?.email; + if (email) { + isInternalUser = email !== 'anon@getgrist.com' && email.endsWith('@getgrist.com'); + } + } + const {category: eventCategory} = TelemetryContracts[event]; this._telemetryLogger.rawLog('info', getEventType(event), event, { ...metadata, eventName: event, + ...(eventCategory !== undefined ? {eventCategory} : undefined), eventSource: `grist-${this._deploymentType}`, installationId: this._activation!.id, + ...(isInternalUser !== undefined ? {isInternalUser} : undefined), }); } private async _forwardEvent( + requestOrSession: RequestOrSession, event: TelemetryEvent, metadata?: TelemetryMetadata ) { if (this._numPendingForwardEventRequests === MAX_PENDING_FORWARD_EVENT_REQUESTS) { - this._logger.warn(undefined, 'exceeded the maximum number of pending forwardEvent calls ' + this._logger.warn(requestOrSession, 'exceeded the maximum number of pending forwardEvent calls ' + `(${MAX_PENDING_FORWARD_EVENT_REQUESTS}). Skipping forwarding of event ${event}.`); return; } try { this._numPendingForwardEventRequests += 1; + const {category: eventCategory} = TelemetryContracts[event]; await this._doForwardEvent(JSON.stringify({ event, metadata: { ...metadata, eventName: event, + ...(eventCategory !== undefined ? {eventCategory} : undefined), eventSource: `grist-${this._deploymentType}`, installationId: this._activation!.id, - } + }, })); } catch (e) { - this._logger.error(undefined, `failed to forward telemetry event ${event}`, e); + this._logger.error(requestOrSession, `failed to forward telemetry event ${event}`, e); } finally { this._numPendingForwardEventRequests -= 1; } @@ -266,6 +320,21 @@ export class Telemetry implements ITelemetry { throw new ApiError('Telemetry is not ready', 500); } } + + private _getLogMeta(requestOrSession?: RequestOrSession) { + if (!requestOrSession) { return {}; } + + if ('get' in requestOrSession) { + return { + org: requestOrSession.org, + email: requestOrSession.user?.loginEmail, + userId: requestOrSession.userId, + altSessionId: requestOrSession.altSessionId, + }; + } else { + return getLogMetaFromDocSession(requestOrSession); + } + } } export async function getTelemetryPrefs( diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 3de1dc1c..dac64ffc 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -83,7 +83,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi gristNewColumnMenu: isAffirmative(process.env.GRIST_NEW_COLUMN_MENU), supportEmail: SUPPORT_EMAIL, userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale, - telemetry: server?.getTelemetry().getTelemetryConfig(), + telemetry: server?.getTelemetry().getTelemetryConfig(req as RequestWithLogin | undefined), deploymentType: server?.getDeploymentType(), templateOrg: getTemplateOrg(), canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE), diff --git a/test/server/lib/Telemetry.ts b/test/server/lib/Telemetry.ts index 08991e34..e0bd199f 100644 --- a/test/server/lib/Telemetry.ts +++ b/test/server/lib/Telemetry.ts @@ -110,7 +110,7 @@ describe('Telemetry', function() { if (deploymentType === 'saas') { it('logs telemetry events', async function() { if (telemetryLevel === 'limited') { - await telemetry.logEvent('documentOpened', { + telemetry.logEvent(null, 'documentOpened', { limited: { docIdDigest: 'digest', isPublic: false, @@ -129,7 +129,7 @@ describe('Telemetry', function() { } if (telemetryLevel === 'full') { - await telemetry.logEvent('documentOpened', { + telemetry.logEvent(null, 'documentOpened', { limited: { docIdDigest: 'digest', isPublic: false, @@ -157,13 +157,14 @@ describe('Telemetry', function() { } else { it('forwards telemetry events', async function() { if (telemetryLevel === 'limited') { - await telemetry.logEvent('documentOpened', { + telemetry.logEvent(null, 'documentOpened', { limited: { docIdDigest: 'digest', isPublic: false, }, }); assert.deepEqual(forwardEventSpy.lastCall.args, [ + null, 'documentOpened', { docIdDigest: 'dige:Vq9L3nCkeufQ8euzDkXtM2Fl1cnsALqakjEeM6QlbXQ=', @@ -174,7 +175,7 @@ describe('Telemetry', function() { } if (telemetryLevel === 'full') { - await telemetry.logEvent('documentOpened', { + telemetry.logEvent(null, 'documentOpened', { limited: { docIdDigest: 'digest', isPublic: false, @@ -184,6 +185,7 @@ describe('Telemetry', function() { }, }); assert.deepEqual(forwardEventSpy.lastCall.args, [ + null, 'documentOpened', { docIdDigest: 'dige:Vq9L3nCkeufQ8euzDkXtM2Fl1cnsALqakjEeM6QlbXQ=', @@ -200,7 +202,7 @@ describe('Telemetry', function() { } } else { it('does not log telemetry events', async function() { - await telemetry.logEvent('documentOpened', { + telemetry.logEvent(null, 'documentOpened', { limited: { docIdDigest: 'digest', isPublic: false, @@ -214,14 +216,14 @@ describe('Telemetry', function() { 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'}}), + telemetry.logEventAsync(null, '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'}}), + telemetry.logEventAsync(null, 'documentOpened', {limited: {invalidMetadata: 'GET'}}), /Unknown metadata for telemetry event documentOpened: invalidMetadata/ ); }); @@ -229,7 +231,7 @@ describe('Telemetry', function() { if (telemetryLevel === 'limited') { 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}}), + telemetry.logEventAsync(null, '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/ ); @@ -251,9 +253,11 @@ describe('Telemetry', function() { if (telemetryLevel === 'limited') { assert.deepEqual(metadata, { eventName: 'watchedVideoTour', + eventCategory: 'Welcome', eventSource: `grist-${deploymentType}`, watchTimeSeconds: 30, installationId, + isInternalUser: true, }); } else { assert.containsAllKeys(metadata, [ @@ -309,7 +313,7 @@ describe('Telemetry', function() { limited: {watchTimeSeconds: 30}, }, }, chimpy); - const [event, metadata] = forwardEventSpy.lastCall.args; + const [, event, metadata] = forwardEventSpy.lastCall.args; assert.equal(event, 'watchedVideoTour'); if (telemetryLevel === 'limited') { assert.deepEqual(metadata, { @@ -330,7 +334,7 @@ describe('Telemetry', function() { } else { // The count below includes 2 apiUsage events triggered as side effects. assert.equal(forwardEventSpy.callCount, 4); - assert.equal(forwardEventSpy.thirdCall.args[0], 'apiUsage'); + assert.equal(forwardEventSpy.thirdCall.args[1], 'apiUsage'); } assert.isEmpty(loggedEvents); }); @@ -345,7 +349,7 @@ describe('Telemetry', function() { // Log enough events simultaneously to cause some to be skipped. (The limit is 25.) for (let i = 0; i < 30; i++) { - void telemetry.logEvent('documentOpened', { + void telemetry.logEvent(null, 'documentOpened', { limited: { docIdDigest: 'digest', isPublic: false, @@ -360,7 +364,7 @@ describe('Telemetry', function() { } } else { it('does not log telemetry events sent to /api/telemetry', async function() { - await telemetry.logEvent('apiUsage', {limited: {method: 'GET'}}); + telemetry.logEvent(null, 'apiUsage', {limited: {method: 'GET'}}); assert.isEmpty(loggedEvents); assert.equal(forwardEventSpy.callCount, 0); });