(core) Add documentCreated telemetry event

Summary:
The event is triggered whenever a document is created, imported, or
duplicated.

Test Plan: Tested manually.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4035
This commit is contained in:
George Gevoian 2023-09-13 00:33:32 -04:00
parent 76e822eb23
commit 40c5f7b738
5 changed files with 141 additions and 9 deletions

View File

@ -353,6 +353,45 @@ export const TelemetryContracts: TelemetryContracts = {
}, },
}, },
}, },
documentCreated: {
description: 'Triggered when a document is created.',
minimumTelemetryLevel: Level.limited,
retentionPeriod: 'indefinitely',
metadataContracts: {
docIdDigest: {
description: 'A hash of the id of the created document.',
dataType: 'string',
},
sourceDocIdDigest: {
description: 'A hash of the id of the source document, if the document was '
+ 'duplicated from an existing document.',
dataType: 'string',
},
isImport: {
description: 'Whether the document was created by import.',
dataType: 'boolean',
},
isSaved: {
description: 'Whether the document was saved to a workspace.',
dataType: 'boolean',
},
fileType: {
description: 'If the document was created by import, the file extension '
+ 'of the file that was imported.',
dataType: 'string',
},
userId: {
description: 'The id of the user that triggered this event.',
dataType: 'number',
minimumTelemetryLevel: Level.full,
},
altSessionId: {
description: 'A random, session-based identifier for the user that triggered this event.',
dataType: 'string',
minimumTelemetryLevel: Level.full,
},
},
},
documentForked: { documentForked: {
description: 'Triggered when a document is forked.', description: 'Triggered when a document is forked.',
minimumTelemetryLevel: Level.limited, minimumTelemetryLevel: Level.limited,
@ -898,6 +937,7 @@ export const TelemetryEvents = StringUnion(
'beaconArticleViewed', 'beaconArticleViewed',
'beaconEmailSent', 'beaconEmailSent',
'beaconSearch', 'beaconSearch',
'documentCreated',
'documentForked', 'documentForked',
'documentOpened', 'documentOpened',
'documentUsage', 'documentUsage',

View File

@ -13,6 +13,7 @@ import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from
import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession'; import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
import {expressWrap} from 'app/server/lib/expressWrap'; import {expressWrap} from 'app/server/lib/expressWrap';
import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {GristServer} from 'app/server/lib/GristServer';
import {getTemplateOrg} from 'app/server/lib/gristSettings'; import {getTemplateOrg} from 'app/server/lib/gristSettings';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam, import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
@ -100,6 +101,7 @@ export class ApiServer {
* to apply to these routes, and trustOrigin too for cross-domain requests. * to apply to these routes, and trustOrigin too for cross-domain requests.
*/ */
constructor( constructor(
private _gristServer: GristServer,
private _app: express.Application, private _app: express.Application,
private _dbManager: HomeDBManager, private _dbManager: HomeDBManager,
private _widgetRepository: IWidgetRepository private _widgetRepository: IWidgetRepository
@ -235,8 +237,23 @@ export class ApiServer {
// POST /api/workspaces/:wid/docs // POST /api/workspaces/:wid/docs
// Create a new doc owned by the specific workspace. // Create a new doc owned by the specific workspace.
this._app.post('/api/workspaces/:wid/docs', expressWrap(async (req, res) => { this._app.post('/api/workspaces/:wid/docs', expressWrap(async (req, res) => {
const mreq = req as RequestWithLogin;
const wsId = integerParam(req.params.wid, 'wid'); const wsId = integerParam(req.params.wid, 'wid');
const query = await this._dbManager.addDocument(getScope(req), wsId, req.body); const query = await this._dbManager.addDocument(getScope(req), wsId, req.body);
this._gristServer.getTelemetry().logEvent('documentCreated', {
limited: {
docIdDigest: query.data!,
sourceDocIdDigest: undefined,
isImport: false,
fileType: undefined,
isSaved: true,
},
full: {
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
})
.catch(e => log.error('failed to log telemetry event documentCreated', e));
return sendReply(req, res, query); return sendReply(req, res, query);
})); }));

View File

@ -17,6 +17,7 @@ import {SchemaTypes} from "app/common/schema";
import {SortFunc} from 'app/common/SortFunc'; import {SortFunc} from 'app/common/SortFunc';
import {Sort} from 'app/common/SortSpec'; import {Sort} from 'app/common/SortSpec';
import {MetaRowRecord} from 'app/common/TableData'; import {MetaRowRecord} from 'app/common/TableData';
import {TelemetryMetadataByLevel} from "app/common/Telemetry";
import {WebhookFields} from "app/common/Triggers"; import {WebhookFields} from "app/common/Triggers";
import TriggersTI from 'app/common/Triggers-ti'; import TriggersTI from 'app/common/Triggers-ti';
import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI'; import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
@ -1150,6 +1151,7 @@ export class DocWorkerApi {
// endpoint is handled only by DocWorker, so is handled here. (Note: this does not handle // endpoint is handled only by DocWorker, so is handled here. (Note: this does not handle
// actual file uploads, so no worries here about large request bodies.) // actual file uploads, so no worries here about large request bodies.)
this._app.post('/api/workspaces/:wid/import', expressWrap(async (req, res) => { this._app.post('/api/workspaces/:wid/import', expressWrap(async (req, res) => {
const mreq = req as RequestWithLogin;
const userId = getUserId(req); const userId = getUserId(req);
const wsId = integerParam(req.params.wid, 'wid'); const wsId = integerParam(req.params.wid, 'wid');
const uploadId = integerParam(req.body.uploadId, 'uploadId'); const uploadId = integerParam(req.body.uploadId, 'uploadId');
@ -1158,6 +1160,16 @@ export class DocWorkerApi {
uploadId, uploadId,
workspaceId: wsId, workspaceId: wsId,
browserSettings: req.body.browserSettings, browserSettings: req.body.browserSettings,
telemetryMetadata: {
limited: {
isImport: true,
sourceDocIdDigest: undefined,
},
full: {
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
},
}); });
res.json(result); res.json(result);
})); }));
@ -1243,6 +1255,7 @@ export class DocWorkerApi {
* TODO: unify this with the other document creation and import endpoints. * TODO: unify this with the other document creation and import endpoints.
*/ */
this._app.post('/api/docs', checkAnonymousCreation, expressWrap(async (req, res) => { this._app.post('/api/docs', checkAnonymousCreation, expressWrap(async (req, res) => {
const mreq = req as RequestWithLogin;
const userId = getUserId(req); const userId = getUserId(req);
let uploadId: number|undefined; let uploadId: number|undefined;
@ -1279,6 +1292,16 @@ export class DocWorkerApi {
documentName: optStringParam(parameters.documentName, 'documentName'), documentName: optStringParam(parameters.documentName, 'documentName'),
workspaceId, workspaceId,
browserSettings, browserSettings,
telemetryMetadata: {
limited: {
isImport: true,
sourceDocIdDigest: undefined,
},
full: {
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
},
}); });
docId = result.id; docId = result.id;
} else if (workspaceId !== undefined) { } else if (workspaceId !== undefined) {
@ -1304,6 +1327,7 @@ export class DocWorkerApi {
documentName: string, documentName: string,
asTemplate?: boolean, asTemplate?: boolean,
}): Promise<string> { }): Promise<string> {
const mreq = req as RequestWithLogin;
const {userId, sourceDocumentId, workspaceId, documentName, asTemplate = false} = options; const {userId, sourceDocumentId, workspaceId, documentName, asTemplate = false} = options;
// First, upload a copy of the document. // First, upload a copy of the document.
@ -1326,6 +1350,16 @@ export class DocWorkerApi {
uploadId: uploadResult.uploadId, uploadId: uploadResult.uploadId,
documentName, documentName,
workspaceId, workspaceId,
telemetryMetadata: {
limited: {
isImport: false,
sourceDocIdDigest: sourceDocumentId,
},
full: {
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
},
}); });
return result.id; return result.id;
} }
@ -1341,7 +1375,15 @@ export class DocWorkerApi {
if (status !== 200) { if (status !== 200) {
throw new ApiError(errMessage || 'unable to create document', status); throw new ApiError(errMessage || 'unable to create document', status);
} }
this._logDocumentCreatedTelemetryEvent(req, {
limited: {
docIdDigest: data!,
sourceDocIdDigest: undefined,
isImport: false,
fileType: undefined,
isSaved: true,
},
});
return data!; return data!;
} }
@ -1365,9 +1407,29 @@ export class DocWorkerApi {
}), }),
docId docId
); );
this._logDocumentCreatedTelemetryEvent(req, {
limited: {
docIdDigest: docId,
sourceDocIdDigest: undefined,
isImport: false,
fileType: undefined,
isSaved: false,
},
});
return docId; return docId;
} }
private _logDocumentCreatedTelemetryEvent(req: Request, metadata: TelemetryMetadataByLevel) {
const mreq = req as RequestWithLogin;
this._grist.getTelemetry().logEvent('documentCreated', _.merge({
full: {
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
}, metadata))
.catch(e => log.error('failed to log telemetry event documentCreated', e));
}
/** /**
* Check for read access to the given document, and return its * Check for read access to the given document, and return its
* canonical docId. Throws error if read access not available. * canonical docId. Throws error if read access not available.

View File

@ -12,6 +12,7 @@ import {DocCreationInfo, DocEntry, DocListAPI, OpenDocMode, OpenLocalDocResult}
import {FilteredDocUsageSummary} from 'app/common/DocUsage'; import {FilteredDocUsageSummary} from 'app/common/DocUsage';
import {Invite} from 'app/common/sharing'; import {Invite} from 'app/common/sharing';
import {tbind} from 'app/common/tbind'; import {tbind} from 'app/common/tbind';
import {TelemetryMetadataByLevel} from 'app/common/Telemetry';
import {NEW_DOCUMENT_CODE} from 'app/common/UserAPI'; import {NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; 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} from 'app/server/lib/Authorizer';
@ -31,6 +32,7 @@ import log from 'app/server/lib/log';
import {ActiveDoc} from './ActiveDoc'; import {ActiveDoc} from './ActiveDoc';
import {PluginManager} from './PluginManager'; import {PluginManager} from './PluginManager';
import {getFileUploadInfo, globalUploadSet, makeAccessId, UploadInfo} from './uploads'; import {getFileUploadInfo, globalUploadSet, makeAccessId, UploadInfo} from './uploads';
import merge = require('lodash/merge');
import noop = require('lodash/noop'); import noop = require('lodash/noop');
// A TTL in milliseconds to use for material that can easily be recomputed / refetched // A TTL in milliseconds to use for material that can easily be recomputed / refetched
@ -202,10 +204,11 @@ export class DocManager extends EventEmitter {
documentName?: string, documentName?: string,
workspaceId?: number, workspaceId?: number,
browserSettings?: BrowserSettings, browserSettings?: BrowserSettings,
telemetryMetadata?: TelemetryMetadataByLevel,
}): Promise<DocCreationInfo> { }): Promise<DocCreationInfo> {
if (!this._homeDbManager) { throw new Error("HomeDbManager not available"); } if (!this._homeDbManager) { throw new Error("HomeDbManager not available"); }
const {userId, uploadId, documentName, workspaceId, browserSettings} = options; const {userId, uploadId, documentName, workspaceId, browserSettings, telemetryMetadata} = options;
const accessId = this.makeAccessId(userId); const accessId = this.makeAccessId(userId);
const docSession = makeExceptionalDocSession('nascent', {browserSettings}); const docSession = makeExceptionalDocSession('nascent', {browserSettings});
const register = async (docId: string, uploadBaseFilename: string) => { const register = async (docId: string, uploadBaseFilename: string) => {
@ -222,13 +225,23 @@ export class DocManager extends EventEmitter {
throw new ApiError(queryResult.errMessage || 'unable to add imported document', queryResult.status); throw new ApiError(queryResult.errMessage || 'unable to add imported document', queryResult.status);
} }
}; };
return this._doImportDoc(docSession, const uploadInfo = globalUploadSet.getUploadInfo(uploadId, accessId);
globalUploadSet.getUploadInfo(uploadId, accessId), { const docCreationInfo = await this._doImportDoc(docSession, uploadInfo, {
naming: workspaceId ? 'saved' : 'unsaved', naming: workspaceId ? 'saved' : 'unsaved',
register, register,
userId, userId,
}); });
this.gristServer.getTelemetry().logEvent('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));
return docCreationInfo;
// The imported document is associated with the worker that did the import. // The imported document is associated with the worker that did the import.
// We could break that association (see /api/docs/:docId/assign for how) if // We could break that association (see /api/docs/:docId/assign for how) if
// we start using dedicated import workers. // we start using dedicated import workers.

View File

@ -670,7 +670,7 @@ export class FlexServer implements GristServer {
// ApiServer's constructor adds endpoints to the app. // ApiServer's constructor adds endpoints to the app.
// tslint:disable-next-line:no-unused-expression // tslint:disable-next-line:no-unused-expression
new ApiServer(this.app, this._dbManager, this._widgetRepository = buildWidgetRepository()); new ApiServer(this, this.app, this._dbManager, this._widgetRepository = buildWidgetRepository());
} }
public addBillingApi() { public addBillingApi() {