Skip to content

Commit fc6f399

Browse files
authored
feat: track all API calls MONGOSH-977 (#1135)
Specifically: - Rename `mongosh:api-call` to `mongosh:api-call-with-arguments` for a bit more accuracy, and drop `mongosh:deprecated-api-call` in favor of a more generla `mongosh:api-call` event that provides information useful for telemetry. - Track top-level API calls of async APIs, avoiding tracking of nested calls and omitting synchronous APIs since they are used internally fairly extensively.
1 parent 677a22c commit fc6f399

18 files changed

+226
-62
lines changed

packages/cli-repl/src/cli-repl.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,19 @@ describe('CliRepl', () => {
804804
expect(loadEvents[1].properties.nested).to.equal(true);
805805
});
806806

807+
it('posts analytics event for shell API calls', async() => {
808+
await cliRepl.start(await testServer.connectionString(), {});
809+
input.write('db.printShardingStatus()\n');
810+
input.write('exit\n');
811+
await waitBus(cliRepl.bus, 'mongosh:closed');
812+
const apiEvents = requests.map(
813+
req => JSON.parse(req.body).batch.filter(entry => entry.event === 'API Call')).flat();
814+
expect(apiEvents).to.have.lengthOf(1);
815+
expect(apiEvents[0].properties.class).to.equal('Database');
816+
expect(apiEvents[0].properties.method).to.equal('printShardingStatus');
817+
expect(apiEvents[0].properties.count).to.equal(1);
818+
});
819+
807820
context('with a 5.0+ server', () => {
808821
skipIfServerVersion(testServer, '<= 4.4');
809822

packages/cli-repl/src/mongosh-repl.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,9 @@ class MongoshNodeRepl implements EvaluationListener {
364364
this.output.write(this.writer(warn) + '\n');
365365
}
366366

367+
(repl as any).on(asyncRepl.evalStart, () => {
368+
this.bus.emit('mongosh:evaluate-started');
369+
});
367370
(repl as any).on(asyncRepl.evalFinish, () => {
368371
this.bus.emit('mongosh:evaluate-finished');
369372
});

packages/logging/src/setup-logger-and-telemetry.spec.ts

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,13 @@ describe('setupLoggerAndTelemetry', () => {
5555
}
5656

5757
bus.emit('mongosh:setCtx', { method: 'setCtx' });
58-
bus.emit('mongosh:api-call', { method: 'auth', class: 'Database', db: 'test-1603986682000', arguments: { } });
59-
bus.emit('mongosh:api-call', { method: 'redactable', arguments: { filter: { email: '[email protected]' } } });
58+
bus.emit('mongosh:api-call-with-arguments', { method: 'auth', class: 'Database', db: 'test-1603986682000', arguments: { } });
59+
bus.emit('mongosh:api-call-with-arguments', { method: 'redactable', arguments: { filter: { email: '[email protected]' } } });
6060
bus.emit('mongosh:evaluate-input', { input: '1+1' });
6161

6262
const circular: any = {};
6363
circular.circular = circular;
64-
bus.emit('mongosh:api-call', { method: 'circulararg', arguments: { options: { circular } } });
64+
bus.emit('mongosh:api-call-with-arguments', { method: 'circulararg', arguments: { options: { circular } } });
6565
expect(circular.circular).to.equal(circular); // Make sure the argument is still intact afterwards
6666

6767
bus.emit('mongosh:start-loading-cli-scripts', { usesShellOption: true });
@@ -374,22 +374,26 @@ describe('setupLoggerAndTelemetry', () => {
374374
logOutput = [];
375375
analyticsOutput = [];
376376

377-
bus.emit('mongosh:deprecated-api-call', { method: 'cloneDatabase', class: 'Database' });
378-
bus.emit('mongosh:deprecated-api-call', { method: 'cloneDatabase', class: 'Database' });
379-
bus.emit('mongosh:deprecated-api-call', { method: 'copyDatabase', class: 'Database' });
380-
bus.emit('mongosh:deprecated-api-call', { method: 'cloneDatabase', class: 'Database' });
377+
bus.emit('mongosh:api-call', { method: 'cloneDatabase', class: 'Database', deprecated: true, callDepth: 0, isAsync: true });
378+
bus.emit('mongosh:api-call', { method: 'cloneDatabase', class: 'Database', deprecated: true, callDepth: 0, isAsync: true });
379+
bus.emit('mongosh:api-call', { method: 'copyDatabase', class: 'Database', deprecated: true, callDepth: 0, isAsync: true });
380+
bus.emit('mongosh:api-call', { method: 'cloneDatabase', class: 'Database', deprecated: true, callDepth: 0, isAsync: true });
381+
bus.emit('mongosh:api-call', { method: 'mangleDatabase', class: 'Database', deprecated: true, callDepth: 1, isAsync: true });
382+
bus.emit('mongosh:api-call', { method: 'getName', class: 'Database', deprecated: false, callDepth: 0, isAsync: false });
381383

382384
expect(logOutput).to.be.empty;
383385
expect(analyticsOutput).to.be.empty;
384386

385387
bus.emit('mongosh:evaluate-finished');
386-
expect(logOutput).to.have.length(2);
387-
expect(analyticsOutput).to.have.length(2);
388+
expect(logOutput).to.have.length(3);
389+
expect(analyticsOutput).to.have.length(5);
388390

389391
expect(logOutput[0].msg).to.equal('Deprecated API call');
390392
expect(logOutput[0].attr).to.deep.equal({ class: 'Database', method: 'cloneDatabase' });
391393
expect(logOutput[1].msg).to.equal('Deprecated API call');
392394
expect(logOutput[1].attr).to.deep.equal({ class: 'Database', method: 'copyDatabase' });
395+
expect(logOutput[2].msg).to.equal('Deprecated API call');
396+
expect(logOutput[2].attr).to.deep.equal({ class: 'Database', method: 'mangleDatabase' });
393397
expect(analyticsOutput).to.deep.equal([
394398
[
395399
'track',
@@ -414,14 +418,52 @@ describe('setupLoggerAndTelemetry', () => {
414418
method: 'copyDatabase',
415419
}
416420
}
417-
]
421+
],
422+
[
423+
'track',
424+
{
425+
userId: '53defe995fa47e6c13102d9d',
426+
event: 'Deprecated Method',
427+
properties: {
428+
mongosh_version: '1.0.0',
429+
class: 'Database',
430+
method: 'mangleDatabase',
431+
}
432+
}
433+
],
434+
[
435+
'track',
436+
{
437+
userId: '53defe995fa47e6c13102d9d',
438+
event: 'API Call',
439+
properties: {
440+
mongosh_version: '1.0.0',
441+
class: 'Database',
442+
method: 'cloneDatabase',
443+
count: 3
444+
}
445+
}
446+
],
447+
[
448+
'track',
449+
{
450+
userId: '53defe995fa47e6c13102d9d',
451+
event: 'API Call',
452+
properties: {
453+
mongosh_version: '1.0.0',
454+
class: 'Database',
455+
method: 'copyDatabase',
456+
count: 1
457+
}
458+
}
459+
],
418460
]);
419461

420462
bus.emit('mongosh:new-user', userId, false);
421463
logOutput = [];
422464
analyticsOutput = [];
423465

424-
bus.emit('mongosh:deprecated-api-call', { method: 'cloneDatabase', class: 'Database' });
466+
bus.emit('mongosh:api-call', { method: 'cloneDatabase', class: 'Database', deprecated: true, callDepth: 0, isAsync: true });
425467

426468
expect(logOutput).to.be.empty;
427469
expect(analyticsOutput).to.be.empty;

packages/logging/src/setup-logger-and-telemetry.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import redactInfo from 'mongodb-redact';
33
import { redactURICredentials } from '@mongosh/history';
44
import type {
55
MongoshBus,
6+
ApiEventWithArguments,
67
ApiEvent,
78
UseEvent,
89
EvaluateInputEvent,
@@ -67,6 +68,28 @@ class NoopAnalytics implements MongoshAnalytics {
6768
track(_info: any): void {} // eslint-disable-line @typescript-eslint/no-unused-vars
6869
}
6970

71+
/**
72+
* A helper class for keeping track of how often specific events occurred.
73+
*/
74+
class MultiSet<T> {
75+
_entries: Map<string, number> = new Map();
76+
77+
add(entry: T): void {
78+
const key = JSON.stringify(Object.entries(entry).sort());
79+
this._entries.set(key, (this._entries.get(key) ?? 0) + 1);
80+
}
81+
82+
clear(): void {
83+
this._entries.clear();
84+
}
85+
86+
*[Symbol.iterator](): Iterator<[T, number]> {
87+
for (const [key, count] of this._entries) {
88+
yield [Object.fromEntries(JSON.parse(key)) as T, count];
89+
}
90+
}
91+
}
92+
7093
/**
7194
* Connect a MongoshBus instance that emits events to logging and analytics providers.
7295
*
@@ -194,11 +217,11 @@ export function setupLoggerAndTelemetry(
194217
}
195218
});
196219

197-
bus.on('mongosh:setCtx', function(args: ApiEvent) {
220+
bus.on('mongosh:setCtx', function(args: ApiEventWithArguments) {
198221
log.info('MONGOSH', mongoLogId(1_000_000_010), 'shell-api', 'Initialized context', args);
199222
});
200223

201-
bus.on('mongosh:api-call', function(args: ApiEvent) {
224+
bus.on('mongosh:api-call-with-arguments', function(args: ApiEventWithArguments) {
202225
// TODO: redactInfo cannot handle circular or otherwise nontrivial input
203226
let arg;
204227
try {
@@ -349,28 +372,54 @@ export function setupLoggerAndTelemetry(
349372
log.info('MONGOSH-SNIPPETS', mongoLogId(1_000_000_032), 'snippets', 'Rewrote error message', ev);
350373
});
351374

352-
const deprecatedApiCalls = new Set<string>();
353-
bus.on('mongosh:deprecated-api-call', function(ev: ApiEvent) {
354-
deprecatedApiCalls.add(`${ev.class}#${ev.method}`);
375+
const deprecatedApiCalls = new MultiSet<Pick<ApiEvent, 'class' | 'method'>>();
376+
const apiCalls = new MultiSet<Pick<ApiEvent, 'class' | 'method'>>();
377+
bus.on('mongosh:api-call', function(ev: ApiEvent) {
378+
if (ev.deprecated) {
379+
deprecatedApiCalls.add({ class: ev.class, method: ev.method });
380+
}
381+
if (ev.callDepth === 0 && ev.isAsync) {
382+
apiCalls.add({ class: ev.class, method: ev.method });
383+
}
384+
});
385+
bus.on('mongosh:evaluate-started', function() {
386+
// Clear API calls before evaluation starts. This is important because
387+
// some API calls are also emitted by mongosh CLI repl internals,
388+
// but we only care about those emitted from user code (i.e. during
389+
// evaluation).
390+
deprecatedApiCalls.clear();
391+
apiCalls.clear();
355392
});
356393
bus.on('mongosh:evaluate-finished', function() {
357-
deprecatedApiCalls.forEach(e => {
358-
const [clazz, method] = e.split('#');
359-
log.warn('MONGOSH', mongoLogId(1_000_000_033), 'shell-api', 'Deprecated API call', { class: clazz, method });
394+
for (const [entry] of deprecatedApiCalls) {
395+
log.warn('MONGOSH', mongoLogId(1_000_000_033), 'shell-api', 'Deprecated API call', entry);
360396

361397
if (telemetry) {
362398
analytics.track({
363399
userId,
364400
event: 'Deprecated Method',
365401
properties: {
366402
mongosh_version,
367-
class: clazz,
368-
method
403+
...entry
369404
}
370405
});
371406
}
372-
});
407+
}
408+
for (const [entry, count] of apiCalls) {
409+
if (telemetry) {
410+
analytics.track({
411+
userId,
412+
event: 'API Call',
413+
properties: {
414+
mongosh_version,
415+
...entry,
416+
count
417+
}
418+
});
419+
}
420+
}
373421
deprecatedApiCalls.clear();
422+
apiCalls.clear();
374423
});
375424

376425
bus.on('mongosh-sp:connect-attempt-initialized', function(ev: SpConnectAttemptInitializedEvent) {

packages/node-runtime-worker-thread/src/index.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ describe('WorkerRuntime', () => {
175175

176176
await runtime.evaluate('db.getCollectionNames()');
177177

178-
expect(eventEmitter.emit).to.have.been.calledWith('mongosh:api-call', {
178+
expect(eventEmitter.emit).to.have.been.calledWith('mongosh:api-call-with-arguments', {
179179
arguments: {},
180180
class: 'Database',
181181
db: 'test',

packages/shell-api/src/bulk.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export default class Bulk extends ShellApiWithMongoClass {
177177
* @private
178178
*/
179179
private _emitBulkApiCall(methodName: string, methodArguments: Document = {}): void {
180-
this._mongo._instanceState.emitApiCall({
180+
this._mongo._instanceState.emitApiCallWithArgs({
181181
method: methodName,
182182
class: 'Bulk',
183183
db: this._collection._database._name,

packages/shell-api/src/collection.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,14 @@ describe('Collection', () => {
6565
});
6666
describe('.collections', () => {
6767
it('allows to get a collection as property if is not one of the existing methods', () => {
68-
const database = new Database({ _instanceState: { emitApiCall: (): void => {} } } as any, 'db1');
68+
const database = new Database({ _instanceState: { emitApiCallWithArgs: (): void => {} } } as any, 'db1');
6969
const coll: any = new Collection({} as any, database, 'coll');
7070
expect(coll.someCollection).to.have.instanceOf(Collection);
7171
expect(coll.someCollection._name).to.equal('coll.someCollection');
7272
});
7373

7474
it('reuses collections', () => {
75-
const database: any = new Database({ _instanceState: { emitApiCall: (): void => {} } } as any, 'db1');
75+
const database: any = new Database({ _instanceState: { emitApiCallWithArgs: (): void => {} } } as any, 'db1');
7676
const coll: any = new Collection({} as any, database, 'coll');
7777
expect(coll.someCollection).to.equal(database.getCollection('coll.someCollection'));
7878
expect(coll.someCollection).to.equal(database.coll.someCollection);

packages/shell-api/src/collection.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export default class Collection extends ShellApiWithMongoClass {
130130
* @private
131131
*/
132132
private _emitCollectionApiCall(methodName: string, methodArguments: Document = {}): void {
133-
this._mongo._instanceState.emitApiCall({
133+
this._mongo._instanceState.emitApiCallWithArgs({
134134
method: methodName,
135135
class: 'Collection',
136136
db: this._database._name,

packages/shell-api/src/database.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export default class Database extends ShellApiWithMongoClass {
126126
* @private
127127
*/
128128
private _emitDatabaseApiCall(methodName: string, methodArguments: Document = {}): void {
129-
this._mongo._instanceState.emitApiCall({
129+
this._mongo._instanceState.emitApiCallWithArgs({
130130
method: methodName,
131131
class: 'Database',
132132
db: this._name,

packages/shell-api/src/decorators.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -230,20 +230,26 @@ function wrapWithApiChecks<T extends(...args: any[]) => any>(fn: T, className: s
230230
const wrapper = (fn as any).returnsPromise ?
231231
markImplicitlyAwaited(async function(this: any, ...args: any[]): Promise<any> {
232232
const instanceState = getShellInstanceState(this);
233-
checkForDeprecation(instanceState, className, fn);
233+
emitApiCallTelemetry(instanceState, className, fn, true);
234234
const interruptFlag = instanceState?.interrupted;
235235
interruptFlag?.checkpoint();
236236
const interrupt = interruptFlag?.asPromise();
237237

238238
let result: any;
239239
try {
240+
if (instanceState) {
241+
instanceState.apiCallDepth++;
242+
}
240243
result = await Promise.race([
241244
interrupt?.promise ?? new Promise<never>(() => {}),
242245
fn.call(this, ...args)
243246
]);
244247
} catch (e) {
245248
throw instanceState?.transformError(e) ?? e;
246249
} finally {
250+
if (instanceState) {
251+
instanceState.apiCallDepth--;
252+
}
247253
if (interrupt) {
248254
interrupt.destroy();
249255
}
@@ -252,14 +258,21 @@ function wrapWithApiChecks<T extends(...args: any[]) => any>(fn: T, className: s
252258
return result;
253259
}) : function(this: any, ...args: any[]): any {
254260
const instanceState = getShellInstanceState(this);
255-
checkForDeprecation(instanceState, className, fn);
261+
emitApiCallTelemetry(instanceState, className, fn, false);
256262
const interruptFlag = instanceState?.interrupted;
257263
interruptFlag?.checkpoint();
258264
let result: any;
259265
try {
266+
if (instanceState) {
267+
instanceState.apiCallDepth++;
268+
}
260269
result = fn.call(this, ...args);
261270
} catch (e) {
262271
throw instanceState?.transformError(e) ?? e;
272+
} finally {
273+
if (instanceState) {
274+
instanceState.apiCallDepth--;
275+
}
263276
}
264277
interruptFlag?.checkpoint();
265278
return result;
@@ -270,20 +283,21 @@ function wrapWithApiChecks<T extends(...args: any[]) => any>(fn: T, className: s
270283
}
271284

272285
/**
273-
* Emit a 'mongosh:deprecated-api-call' event on the instance state's message bus
274-
* if the function was marked with the {@link deprecated} decorator.
286+
* Emit a 'mongosh:api-call' event on the instance state's message bus,
287+
* with the 'deprecated' property set if the function was marked with
288+
* the {@link deprecated} decorator.
275289
*
276290
* @param instanceState A ShellInstanceState object.
277291
* @param className The name of the class in question.
278292
* @param fn The class method in question.
279293
*/
280-
function checkForDeprecation(instanceState: ShellInstanceState | undefined, className: string, fn: Function) {
281-
if (instanceState && typeof instanceState.emitDeprecatedApiCall === 'function' && typeof fn === 'function' && (fn as any).deprecated) {
282-
instanceState.emitDeprecatedApiCall({
283-
method: fn.name,
284-
class: className
285-
});
286-
}
294+
function emitApiCallTelemetry(instanceState: ShellInstanceState | undefined, className: string, fn: Function, isAsync: boolean) {
295+
instanceState?.emitApiCall?.({
296+
method: fn.name,
297+
class: className,
298+
deprecated: !!(fn as any).deprecated,
299+
isAsync
300+
});
287301
}
288302

289303
/**

0 commit comments

Comments
 (0)