(core) Tweak telemetry

Summary: Adjusts the level of telemetry collected from Grist SaaS.

Test Plan: Tested manually.

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D3899
This commit is contained in:
George Gevoian 2023-05-18 18:35:39 -04:00
parent be5cb9124a
commit 1e873b4203
13 changed files with 107 additions and 49 deletions

View File

@ -2,10 +2,10 @@ import {logError} from 'app/client/models/errors';
import {TelemetryEventName} from 'app/common/Telemetry'; import {TelemetryEventName} from 'app/common/Telemetry';
import {fetchFromHome, pageHasHome} from 'app/common/urlUtils'; import {fetchFromHome, pageHasHome} from 'app/common/urlUtils';
export async function logTelemetryEvent(name: TelemetryEventName, metadata?: Record<string, any>) { export function logTelemetryEvent(name: TelemetryEventName, metadata?: Record<string, any>) {
if (!pageHasHome()) { return; } if (!pageHasHome()) { return; }
await fetchFromHome('/api/telemetry', { fetchFromHome('/api/telemetry', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name, name,

View File

@ -36,7 +36,7 @@ const VIDEO_TOUR_YOUTUBE_EMBED_ID = 'qnr2Pfnxdlc';
owner.onDispose(async () => { owner.onDispose(async () => {
if (youtubePlayer.isLoading()) { return; } if (youtubePlayer.isLoading()) { return; }
await logTelemetryEvent('watchedVideoTour', { logTelemetryEvent('watchedVideoTour', {
watchTimeSeconds: Math.floor(youtubePlayer.getCurrentTime()), watchTimeSeconds: Math.floor(youtubePlayer.getCurrentTime()),
}); });
}); });

View File

@ -0,0 +1,12 @@
import {createHash} from 'crypto';
/**
* Returns a hash of `id` prefixed with the first 4 characters of `id`.
*
* Useful for situations where potentially sensitive identifiers are logged, such as
* doc ids (like those that have public link sharing enabled). The first 4 characters
* are included to assist with troubleshooting.
*/
export function hashId(id: string): string {
return `${id.slice(0, 4)}:${createHash('sha256').update(id.slice(4)).digest('base64')}`;
}

View File

@ -1,5 +1,6 @@
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {DocumentUsage} from 'app/common/DocUsage'; import {DocumentUsage} from 'app/common/DocUsage';
import {hashId} from 'app/common/hashingUtils';
import {Role} from 'app/common/roles'; import {Role} from 'app/common/roles';
import {DocumentOptions, DocumentProperties, documentPropertyKeys, import {DocumentOptions, DocumentProperties, documentPropertyKeys,
DocumentType, NEW_DOCUMENT_CODE, TutorialMetadata} from "app/common/UserAPI"; DocumentType, NEW_DOCUMENT_CODE, TutorialMetadata} from "app/common/UserAPI";
@ -164,8 +165,8 @@ export class Document extends Resource {
? Math.floor((lastSlideIndex / numSlides) * 100) ? Math.floor((lastSlideIndex / numSlides) * 100)
: undefined; : undefined;
dbManager?.emit('tutorialProgressChange', { dbManager?.emit('tutorialProgressChange', {
tutorialForkId: this.id, tutorialForkIdDigest: hashId(this.id),
tutorialTrunkId: this.trunkId, tutorialTrunkIdDigest: this.trunkId ? hashId(this.trunkId) : undefined,
lastSlideIndex, lastSlideIndex,
numSlides, numSlides,
percentComplete, percentComplete,

View File

@ -17,7 +17,8 @@ import fetch from 'node-fetch';
import * as Fetch from 'node-fetch'; import * as Fetch from 'node-fetch';
import { EntityManager } from 'typeorm'; import { EntityManager } from 'typeorm';
const HOUSEKEEPER_PERIOD_MS = 1 * 60 * 60 * 1000; // operate every 1 hour const DELETE_TRASH_PERIOD_MS = 1 * 60 * 60 * 1000; // operate every 1 hour
const LOG_METRICS_PERIOD_MS = 24 * 60 * 60 * 1000; // operate every day
const AGE_THRESHOLD_OFFSET = '-30 days'; // should be an interval known by postgres + sqlite const AGE_THRESHOLD_OFFSET = '-30 days'; // should be an interval known by postgres + sqlite
/** /**
@ -33,7 +34,8 @@ const AGE_THRESHOLD_OFFSET = '-30 days'; // should be an interval kno
* multiple home servers, there will be no competition or duplication of effort. * multiple home servers, there will be no competition or duplication of effort.
*/ */
export class Housekeeper { export class Housekeeper {
private _interval?: NodeJS.Timeout; private _deleteTrashinterval?: NodeJS.Timeout;
private _logMetricsInterval?: NodeJS.Timeout;
private _electionKey?: string; private _electionKey?: string;
public constructor(private _dbManager: HomeDBManager, private _server: GristServer, public constructor(private _dbManager: HomeDBManager, private _server: GristServer,
@ -45,34 +47,35 @@ export class Housekeeper {
*/ */
public async start() { public async start() {
await this.stop(); await this.stop();
this._interval = setInterval(() => { this._deleteTrashinterval = setInterval(() => {
this.doHousekeepingExclusively().catch(log.warn.bind(log)); this.deleteTrashExclusively().catch(log.warn.bind(log));
}, HOUSEKEEPER_PERIOD_MS); }, DELETE_TRASH_PERIOD_MS);
this._logMetricsInterval = setInterval(() => {
this.logMetricsExclusively().catch(log.warn.bind(log));
}, LOG_METRICS_PERIOD_MS);
} }
/** /**
* Stop scheduling housekeeping tasks. Note: doesn't wait for any housekeeping task in progress. * Stop scheduling housekeeping tasks. Note: doesn't wait for any housekeeping task in progress.
*/ */
public async stop() { public async stop() {
if (this._interval) { for (const interval of ['_deleteTrashinterval', '_logMetricsInterval'] as const) {
clearInterval(this._interval); clearInterval(this[interval]);
this._interval = undefined; this[interval] = undefined;
} }
} }
/** /**
* Deletes old trash and logs metrics if no other server is working on it or worked on it * Deletes old trash if no other server is working on it or worked on it recently.
* recently.
*/ */
public async doHousekeepingExclusively(): Promise<boolean> { public async deleteTrashExclusively(): Promise<boolean> {
const electionKey = await this._electionStore.getElection('housekeeping', HOUSEKEEPER_PERIOD_MS / 2.0); const electionKey = await this._electionStore.getElection('housekeeping', DELETE_TRASH_PERIOD_MS / 2.0);
if (!electionKey) { if (!electionKey) {
log.info('Skipping housekeeping since another server is working on it or worked on it recently'); log.info('Skipping deleteTrash since another server is working on it or worked on it recently');
return false; return false;
} }
this._electionKey = electionKey; this._electionKey = electionKey;
await this.deleteTrash(); await this.deleteTrash();
await this.logMetrics();
return true; return true;
} }
@ -152,6 +155,20 @@ export class Housekeeper {
} }
} }
/**
* Logs metrics if no other server is working on it or worked on it recently.
*/
public async logMetricsExclusively(): Promise<boolean> {
const electionKey = await this._electionStore.getElection('logMetrics', LOG_METRICS_PERIOD_MS / 2.0);
if (!electionKey) {
log.info('Skipping logMetrics since another server is working on it or worked on it recently');
return false;
}
this._electionKey = electionKey;
await this.logMetrics();
return true;
}
/** /**
* Logs metrics regardless of what other servers may be doing. * Logs metrics regardless of what other servers may be doing.
*/ */
@ -163,7 +180,7 @@ export class Housekeeper {
telemetryManager?.logEvent('siteUsage', { telemetryManager?.logEvent('siteUsage', {
siteId: summary.site_id, siteId: summary.site_id,
siteType: summary.site_type, siteType: summary.site_type,
inGoodStanding: summary.in_good_standing, inGoodStanding: Boolean(summary.in_good_standing),
stripePlanId: summary.stripe_plan_id, stripePlanId: summary.stripe_plan_id,
numDocs: Number(summary.num_docs), numDocs: Number(summary.num_docs),
numWorkspaces: Number(summary.num_workspaces), numWorkspaces: Number(summary.num_workspaces),

View File

@ -68,6 +68,7 @@ import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccess
import {isHiddenCol} from 'app/common/gristTypes'; import {isHiddenCol} from 'app/common/gristTypes';
import {commonUrls, parseUrlId} from 'app/common/gristUrls'; import {commonUrls, parseUrlId} from 'app/common/gristUrls';
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil'; import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
import {hashId} from 'app/common/hashingUtils';
import {InactivityTimer} from 'app/common/InactivityTimer'; import {InactivityTimer} from 'app/common/InactivityTimer';
import {Interval} from 'app/common/Interval'; import {Interval} from 'app/common/Interval';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
@ -131,10 +132,12 @@ import {createAttachmentsIndex, DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY} fro
import {expandQuery} from './ExpandedQuery'; import {expandQuery} from './ExpandedQuery';
import {GranularAccess, GranularAccessForBundle} from './GranularAccess'; import {GranularAccess, GranularAccessForBundle} from './GranularAccess';
import {OnDemandActions} from './OnDemandActions'; import {OnDemandActions} from './OnDemandActions';
import {getLogMetaFromDocSession, getPubSubPrefix, timeoutReached} from './serverUtils'; import {getLogMetaFromDocSession, getPubSubPrefix, getTelemetryMetaFromDocSession,
timeoutReached} from './serverUtils';
import {findOrAddAllEnvelope, Sharing} from './Sharing'; import {findOrAddAllEnvelope, Sharing} from './Sharing';
import cloneDeep = require('lodash/cloneDeep'); import cloneDeep = require('lodash/cloneDeep');
import flatten = require('lodash/flatten'); import flatten = require('lodash/flatten');
import merge = require('lodash/merge');
import pick = require('lodash/pick'); import pick = require('lodash/pick');
import remove = require('lodash/remove'); import remove = require('lodash/remove');
import sum = require('lodash/sum'); import sum = require('lodash/sum');
@ -1389,10 +1392,9 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
// TODO: Need a more precise way to identify a template. (This org now also has tutorials.) // TODO: Need a more precise way to identify a template. (This org now also has tutorials.)
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial'; const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial';
this.logTelemetryEvent(docSession, 'documentForked', { this.logTelemetryEvent(docSession, 'documentForked', {
forkId: forkIds.forkId, forkIdDigest: hashId(forkIds.forkId),
forkDocId: forkIds.docId, forkDocIdDigest: hashId(forkIds.docId),
forkUrlId: forkIds.urlId, trunkIdDigest: doc.trunkId ? hashId(doc.trunkId) : undefined,
trunkId: doc.trunkId,
isTemplate, isTemplate,
lastActivity: doc.updatedAt, lastActivity: doc.updatedAt,
}); });
@ -2314,7 +2316,6 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
private _logDocMetrics(docSession: OptDocSession, triggeredBy: 'docOpen' | 'interval'| 'docClose') { private _logDocMetrics(docSession: OptDocSession, triggeredBy: 'docOpen' | 'interval'| 'docClose') {
this.logTelemetryEvent(docSession, 'documentUsage', { this.logTelemetryEvent(docSession, 'documentUsage', {
triggeredBy, triggeredBy,
access: this._doc?.access,
isPublic: ((this._doc as unknown) as APIDocument)?.public ?? false, isPublic: ((this._doc as unknown) as APIDocument)?.public ?? false,
rowCount: this._docUsage?.rowCount?.total, rowCount: this._docUsage?.rowCount?.total,
dataSizeBytes: this._docUsage?.dataSizeBytes, dataSizeBytes: this._docUsage?.dataSizeBytes,
@ -2434,22 +2435,22 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
private _getCustomWidgetMetrics() { private _getCustomWidgetMetrics() {
const viewSections = this.docData?.getMetaTable('_grist_Views_section'); const viewSections = this.docData?.getMetaTable('_grist_Views_section');
const viewSectionRecords = viewSections?.getRecords() ?? []; const viewSectionRecords = viewSections?.getRecords() ?? [];
const customWidgetUrls: string[] = []; const customWidgetIds: string[] = [];
for (const r of viewSectionRecords) { for (const r of viewSectionRecords) {
const {customView} = safeJsonParse(r.options, {}); const {customView} = safeJsonParse(r.options, {});
if (!customView) { continue; } if (!customView) { continue; }
const {url} = safeJsonParse(customView, {}); const {pluginId, url} = safeJsonParse(customView, {});
if (!url) { continue; } if (!url) { continue; }
const isGristUrl = url.startsWith(commonUrls.gristLabsCustomWidgets); const isGristLabsWidget = url.startsWith(commonUrls.gristLabsCustomWidgets);
customWidgetUrls.push(isGristUrl ? url : 'externalURL'); customWidgetIds.push(isGristLabsWidget ? pluginId : 'externalId');
} }
const numCustomWidgets = customWidgetUrls.length; const numCustomWidgets = customWidgetIds.length;
return { return {
numCustomWidgets, numCustomWidgets,
customWidgetUrls, customWidgetIds,
}; };
} }
@ -2511,15 +2512,16 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
} }
private _getTelemetryMeta(docSession: OptDocSession|null) { private _getTelemetryMeta(docSession: OptDocSession|null) {
return { const altSessionId = docSession ? getDocSessionAltSessionId(docSession) : undefined;
...(docSession ? { return merge(
...getLogMetaFromDocSession(docSession), docSession ? getTelemetryMetaFromDocSession(docSession) : {},
altSessionId: getDocSessionAltSessionId(docSession), altSessionId ? {altSessionId} : undefined,
} : {}), {
docId: this._docName, docIdDigest: hashId(this._docName),
siteId: this._doc?.workspace.org.id, siteId: this._doc?.workspace.org.id,
siteType: this._product?.name, siteType: this._product?.name,
}; },
);
} }
/** /**

View File

@ -9,6 +9,7 @@ import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch';
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {getSlugIfNeeded, parseSubdomainStrictly, parseUrlId} from 'app/common/gristUrls'; import {getSlugIfNeeded, parseSubdomainStrictly, parseUrlId} from 'app/common/gristUrls';
import {removeTrailingSlash} from 'app/common/gutil'; import {removeTrailingSlash} from 'app/common/gutil';
import {hashId} from 'app/common/hashingUtils';
import {LocalPlugin} from "app/common/plugin"; import {LocalPlugin} from "app/common/plugin";
import {TelemetryTemplateSignupCookieName} from 'app/common/Telemetry'; import {TelemetryTemplateSignupCookieName} from 'app/common/Telemetry';
import {Document as APIDocument} from 'app/common/UserAPI'; import {Document as APIDocument} from 'app/common/UserAPI';
@ -308,7 +309,7 @@ export function attachAppEndpoint(options: AttachOptions): void {
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial'; const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial';
if (isPublic || isTemplate) { if (isPublic || isTemplate) {
gristServer.getTelemetryManager()?.logEvent('documentOpened', { gristServer.getTelemetryManager()?.logEvent('documentOpened', {
docId, docIdDigest: hashId(docId),
siteId: doc.workspace.org.id, siteId: doc.workspace.org.id,
siteType: doc.workspace.org.billingAccount.product.name, siteType: doc.workspace.org.billingAccount.product.name,
userId: mreq.userId, userId: mreq.userId,

View File

@ -397,7 +397,8 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
log.rawDebug(`Auth[${meta.method}]: ${meta.host} ${meta.path}`, meta); log.rawDebug(`Auth[${meta.method}]: ${meta.host} ${meta.path}`, meta);
if (hasApiKey) { if (hasApiKey) {
options.gristServer.getTelemetryManager()?.logEvent('apiUsage', { options.gristServer.getTelemetryManager()?.logEvent('apiUsage', {
...meta, method: mreq.method,
userId: mreq.userId,
userAgent: req.headers['user-agent'], userAgent: req.headers['user-agent'],
}); });
} }

View File

@ -433,6 +433,16 @@ export class Client {
return meta; return meta;
} }
public getFullTelemetryMeta() {
const meta: Record<string, any> = {};
// We assume the _userId has already been cached, which will be true always (for all practical
// purposes) because it's set when the Authorizer checks this client.
if (this._userId) { meta.userId = this._userId; }
const altSessionId = this.getAltSessionId();
if (altSessionId) { meta.altSessionId = altSessionId; }
return meta;
}
private async _refreshUser(dbManager: HomeDBManager) { private async _refreshUser(dbManager: HomeDBManager) {
if (this._profile) { if (this._profile) {
const user = await this._fetchUser(dbManager); const user = await this._fetchUser(dbManager);

View File

@ -11,6 +11,7 @@ import {
import {isRaisedException} from "app/common/gristTypes"; import {isRaisedException} from "app/common/gristTypes";
import {buildUrlId, parseUrlId} from "app/common/gristUrls"; import {buildUrlId, parseUrlId} from "app/common/gristUrls";
import {isAffirmative} from "app/common/gutil"; import {isAffirmative} from "app/common/gutil";
import {hashId} from "app/common/hashingUtils";
import {SchemaTypes} from "app/common/schema"; 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';
@ -915,9 +916,8 @@ export class DocWorkerApi {
}); });
const {forkId} = parseUrlId(scope.urlId); const {forkId} = parseUrlId(scope.urlId);
activeDoc.logTelemetryEvent(docSession, 'tutorialRestarted', { activeDoc.logTelemetryEvent(docSession, 'tutorialRestarted', {
tutorialForkId: forkId, tutorialForkIdDigest: forkId ? hashId(forkId) : undefined,
tutorialForkUrlId: scope.urlId, tutorialTrunkIdDigest: hashId(tutorialTrunkId),
tutorialTrunkId,
}); });
} }
} }

View File

@ -697,9 +697,7 @@ export class FlexServer implements GristServer {
const name = stringParam(req.body.name, 'name', TelemetryEventNames); const name = stringParam(req.body.name, 'name', TelemetryEventNames);
this._telemetryManager?.logEvent(name as TelemetryEventName, { this._telemetryManager?.logEvent(name as TelemetryEventName, {
userId: mreq.userId, userId: mreq.userId,
email: mreq.user?.loginEmail,
altSessionId: mreq.altSessionId, altSessionId: mreq.altSessionId,
site: mreq.org,
...req.body.metadata, ...req.body.metadata,
}); });
return resp.status(200).send(); return resp.status(200).send();

View File

@ -619,7 +619,9 @@ export class DocTriggers {
await this._stats.logStatus(id, 'sending'); await this._stats.logStatus(id, 'sending');
meta = {numEvents: batch.length, webhookId: id, host: new URL(url).host}; meta = {numEvents: batch.length, webhookId: id, host: new URL(url).host};
this._log("Sending batch of webhook events", meta); this._log("Sending batch of webhook events", meta);
this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', meta); this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', {
numEvents: batch.length,
});
success = await this._sendWebhookWithRetries(id, url, body, batch.length, this._loopAbort.signal); success = await this._sendWebhookWithRetries(id, url, body, batch.length, this._loopAbort.signal);
if (this._loopAbort.signal.aborted) { if (this._loopAbort.signal.aborted) {
continue; continue;

View File

@ -163,6 +163,20 @@ export function getLogMetaFromDocSession(docSession: OptDocSession) {
}; };
} }
/**
* Extract telemetry metadata from session.
*/
export function getTelemetryMetaFromDocSession(docSession: OptDocSession) {
const client = docSession.client;
const access = getDocSessionAccessOrNull(docSession);
const user = getDocSessionUser(docSession);
return {
access,
...(user ? {userId: user.id} : {}),
...(client ? client.getFullTelemetryMeta() : {}), // Client if present will repeat and add to user info.
};
}
/** /**
* Only offer choices of engine on experimental deployments (staging/dev). * Only offer choices of engine on experimental deployments (staging/dev).
*/ */