(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: {
description: 'Triggered when a document is forked.',
minimumTelemetryLevel: Level.limited,
@ -898,6 +937,7 @@ export const TelemetryEvents = StringUnion(
'beaconArticleViewed',
'beaconEmailSent',
'beaconSearch',
'documentCreated',
'documentForked',
'documentOpened',
'documentUsage',

View File

@ -13,6 +13,7 @@ import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from
import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
import {expressWrap} from 'app/server/lib/expressWrap';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {GristServer} from 'app/server/lib/GristServer';
import {getTemplateOrg} from 'app/server/lib/gristSettings';
import log from 'app/server/lib/log';
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.
*/
constructor(
private _gristServer: GristServer,
private _app: express.Application,
private _dbManager: HomeDBManager,
private _widgetRepository: IWidgetRepository
@ -235,8 +237,23 @@ export class ApiServer {
// POST /api/workspaces/:wid/docs
// Create a new doc owned by the specific workspace.
this._app.post('/api/workspaces/:wid/docs', expressWrap(async (req, res) => {
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', {
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);
}));

View File

@ -17,6 +17,7 @@ import {SchemaTypes} from "app/common/schema";
import {SortFunc} from 'app/common/SortFunc';
import {Sort} from 'app/common/SortSpec';
import {MetaRowRecord} from 'app/common/TableData';
import {TelemetryMetadataByLevel} from "app/common/Telemetry";
import {WebhookFields} from "app/common/Triggers";
import TriggersTI from 'app/common/Triggers-ti';
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
// actual file uploads, so no worries here about large request bodies.)
this._app.post('/api/workspaces/:wid/import', expressWrap(async (req, res) => {
const mreq = req as RequestWithLogin;
const userId = getUserId(req);
const wsId = integerParam(req.params.wid, 'wid');
const uploadId = integerParam(req.body.uploadId, 'uploadId');
@ -1158,6 +1160,16 @@ export class DocWorkerApi {
uploadId,
workspaceId: wsId,
browserSettings: req.body.browserSettings,
telemetryMetadata: {
limited: {
isImport: true,
sourceDocIdDigest: undefined,
},
full: {
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
},
});
res.json(result);
}));
@ -1243,6 +1255,7 @@ export class DocWorkerApi {
* TODO: unify this with the other document creation and import endpoints.
*/
this._app.post('/api/docs', checkAnonymousCreation, expressWrap(async (req, res) => {
const mreq = req as RequestWithLogin;
const userId = getUserId(req);
let uploadId: number|undefined;
@ -1279,6 +1292,16 @@ export class DocWorkerApi {
documentName: optStringParam(parameters.documentName, 'documentName'),
workspaceId,
browserSettings,
telemetryMetadata: {
limited: {
isImport: true,
sourceDocIdDigest: undefined,
},
full: {
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
},
});
docId = result.id;
} else if (workspaceId !== undefined) {
@ -1304,6 +1327,7 @@ export class DocWorkerApi {
documentName: string,
asTemplate?: boolean,
}): Promise<string> {
const mreq = req as RequestWithLogin;
const {userId, sourceDocumentId, workspaceId, documentName, asTemplate = false} = options;
// First, upload a copy of the document.
@ -1326,6 +1350,16 @@ export class DocWorkerApi {
uploadId: uploadResult.uploadId,
documentName,
workspaceId,
telemetryMetadata: {
limited: {
isImport: false,
sourceDocIdDigest: sourceDocumentId,
},
full: {
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
},
});
return result.id;
}
@ -1341,7 +1375,15 @@ export class DocWorkerApi {
if (status !== 200) {
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!;
}
@ -1365,9 +1407,29 @@ export class DocWorkerApi {
}),
docId
);
this._logDocumentCreatedTelemetryEvent(req, {
limited: {
docIdDigest: docId,
sourceDocIdDigest: undefined,
isImport: false,
fileType: undefined,
isSaved: false,
},
});
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
* 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 {Invite} from 'app/common/sharing';
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';
@ -31,6 +32,7 @@ import log from 'app/server/lib/log';
import {ActiveDoc} from './ActiveDoc';
import {PluginManager} from './PluginManager';
import {getFileUploadInfo, globalUploadSet, makeAccessId, UploadInfo} from './uploads';
import merge = require('lodash/merge');
import noop = require('lodash/noop');
// 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,
workspaceId?: number,
browserSettings?: BrowserSettings,
telemetryMetadata?: TelemetryMetadataByLevel,
}): Promise<DocCreationInfo> {
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 docSession = makeExceptionalDocSession('nascent', {browserSettings});
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);
}
};
return this._doImportDoc(docSession,
globalUploadSet.getUploadInfo(uploadId, accessId), {
naming: workspaceId ? 'saved' : 'unsaved',
register,
userId,
});
const uploadInfo = globalUploadSet.getUploadInfo(uploadId, accessId);
const docCreationInfo = await this._doImportDoc(docSession, uploadInfo, {
naming: workspaceId ? 'saved' : 'unsaved',
register,
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.
// We could break that association (see /api/docs/:docId/assign for how) if
// 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.
// 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() {