@mongosh/logging 1.10.1 → 1.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,76 @@
1
- export type MongoshAnalyticsIdentity = {
2
- userId: string;
3
- } | {
4
- anonymousId: string;
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ export type MongoshAnalyticsIdentity =
5
+ | {
6
+ userId: string;
7
+ anonymousId?: never;
8
+ }
9
+ | {
10
+ userId?: never;
11
+ anonymousId: string;
12
+ };
13
+
14
+ type AnalyticsIdentifyMessage = MongoshAnalyticsIdentity & {
15
+ traits: { platform: string };
16
+ };
17
+
18
+ type AnalyticsTrackMessage = MongoshAnalyticsIdentity & {
19
+ event: string;
20
+ properties: {
21
+ mongosh_version: string;
22
+ [key: string]: any;
23
+ };
5
24
  };
6
25
 
7
26
  /**
8
27
  * General interface for an Analytics provider that mongosh can use.
9
28
  */
10
29
  export interface MongoshAnalytics {
11
- identify(message: MongoshAnalyticsIdentity & {
12
- traits: { platform: string }
13
- }): void;
14
-
15
- track(message: MongoshAnalyticsIdentity & {
16
- event: string,
17
- properties: {
18
- // eslint-disable-next-line camelcase
19
- mongosh_version: string,
20
- [key: string]: any;
21
- }
22
- }): void;
30
+ identify(message: AnalyticsIdentifyMessage): void;
31
+
32
+ track(message: AnalyticsTrackMessage): void;
33
+
34
+ // NB: Callback and not a promise to match segment analytics interface so it's
35
+ // easier to pass it to the helpers constructor
36
+ flush(callback: (err?: Error) => void): void;
37
+ }
38
+
39
+ class Queue<T> {
40
+ private queue: T[] = [];
41
+ private state: 'paused' | 'enabled' | 'disabled' = 'paused';
42
+ constructor(private applyFn: (val: T) => void) {}
43
+ push(val: T) {
44
+ switch (this.state) {
45
+ case 'paused':
46
+ this.queue.push(val);
47
+ return;
48
+ case 'enabled':
49
+ this.applyFn(val);
50
+ return;
51
+ case 'disabled':
52
+ default:
53
+ return;
54
+ }
55
+ }
56
+ enable() {
57
+ this.state = 'enabled';
58
+ const queue = this.queue;
59
+ this.queue = [];
60
+ queue.forEach((val) => {
61
+ this.applyFn(val);
62
+ });
63
+ }
64
+ disable() {
65
+ this.state = 'disabled';
66
+ this.queue = [];
67
+ }
68
+ pause() {
69
+ this.state = 'paused';
70
+ }
71
+ getState() {
72
+ return this.state;
73
+ }
23
74
  }
24
75
 
25
76
  /**
@@ -28,17 +79,30 @@ export interface MongoshAnalytics {
28
79
  * (e.g. because we are running without an API key).
29
80
  */
30
81
  export class NoopAnalytics implements MongoshAnalytics {
31
- identify(_info: any): void {} // eslint-disable-line @typescript-eslint/no-unused-vars
32
- track(_info: any): void {} // eslint-disable-line @typescript-eslint/no-unused-vars
82
+ identify(_info: any): void {}
83
+ track(_info: any): void {}
84
+ flush(cb: () => void) {
85
+ cb();
86
+ }
33
87
  }
34
88
 
89
+ type AnalyticsEventsQueueItem =
90
+ | ['identify', Parameters<MongoshAnalytics['identify']>]
91
+ | ['track', Parameters<MongoshAnalytics['track']>];
92
+
35
93
  /**
36
94
  * An implementation of MongoshAnalytics that forwards to another implementation
37
95
  * and can be enabled/paused/disabled.
38
96
  */
39
97
  export class ToggleableAnalytics implements MongoshAnalytics {
40
- _queue: Array<['identify', Parameters<MongoshAnalytics['identify']>] | ['track', Parameters<MongoshAnalytics['track']>]> = [];
41
- _state: 'enabled' | 'disabled' | 'paused' = 'paused';
98
+ _queue = new Queue<AnalyticsEventsQueueItem>((item) => {
99
+ if (item[0] === 'identify') {
100
+ this._target.identify(...item[1]);
101
+ }
102
+ if (item[0] === 'track') {
103
+ this._target.track(...item[1]);
104
+ }
105
+ });
42
106
  _target: MongoshAnalytics;
43
107
  _pendingError?: Error;
44
108
 
@@ -48,53 +112,28 @@ export class ToggleableAnalytics implements MongoshAnalytics {
48
112
 
49
113
  identify(...args: Parameters<MongoshAnalytics['identify']>): void {
50
114
  this._validateArgs(args);
51
- switch (this._state) {
52
- case 'enabled':
53
- this._target.identify(...args);
54
- break;
55
- case 'paused':
56
- this._queue.push(['identify', args]);
57
- break;
58
- default:
59
- break;
60
- }
115
+ this._queue.push(['identify', args]);
61
116
  }
62
117
 
63
118
  track(...args: Parameters<MongoshAnalytics['track']>): void {
64
119
  this._validateArgs(args);
65
- switch (this._state) {
66
- case 'enabled':
67
- this._target.track(...args);
68
- break;
69
- case 'paused':
70
- this._queue.push(['track', args]);
71
- break;
72
- default:
73
- break;
74
- }
120
+ this._queue.push(['track', args]);
75
121
  }
76
122
 
77
123
  enable() {
78
124
  if (this._pendingError) {
79
125
  throw this._pendingError;
80
126
  }
81
- this._state = 'enabled';
82
- const queue = this._queue;
83
- this._queue = [];
84
- for (const entry of queue) {
85
- if (entry[0] === 'identify') this.identify(...entry[1]);
86
- if (entry[0] === 'track') this.track(...entry[1]);
87
- }
127
+ this._queue.enable();
88
128
  }
89
129
 
90
130
  disable() {
91
- this._state = 'disabled';
92
131
  this._pendingError = undefined;
93
- this._queue = [];
132
+ this._queue.disable();
94
133
  }
95
134
 
96
135
  pause() {
97
- this._state = 'paused';
136
+ this._queue.pause();
98
137
  }
99
138
 
100
139
  _validateArgs([firstArg]: [MongoshAnalyticsIdentity]): void {
@@ -105,10 +144,12 @@ export class ToggleableAnalytics implements MongoshAnalytics {
105
144
  // stack trace information for where the buggy call came from, and two,
106
145
  // this way the validation affects all tests in CI, not just the ones that
107
146
  // are explicitly written to enable telemetry to a fake endpoint.
108
- if (!('userId' in firstArg && firstArg.userId) &&
109
- !('anonymousId' in firstArg && firstArg.anonymousId)) {
147
+ if (
148
+ !('userId' in firstArg && firstArg.userId) &&
149
+ !('anonymousId' in firstArg && firstArg.anonymousId)
150
+ ) {
110
151
  const err = new Error('Telemetry setup is missing userId or anonymousId');
111
- switch (this._state) {
152
+ switch (this._queue.getState()) {
112
153
  case 'enabled':
113
154
  throw err;
114
155
  case 'paused':
@@ -119,4 +160,211 @@ export class ToggleableAnalytics implements MongoshAnalytics {
119
160
  }
120
161
  }
121
162
  }
163
+
164
+ flush(callback: (err?: Error | undefined) => void): void {
165
+ return this._target.flush(callback);
166
+ }
167
+ }
168
+
169
+ type ThrottledAnalyticsOptions = {
170
+ target: MongoshAnalytics;
171
+ /**
172
+ * Throttling options. If not provided, throttling is disabled (default: null)
173
+ */
174
+ throttle: {
175
+ /** Allowed events per timeframe number */
176
+ rate: number;
177
+ /** Timeframe for throttling in milliseconds (default: 60_000ms) */
178
+ timeframe?: number;
179
+ /** Path to persist rpm value to be able to track them between sessions */
180
+ metadataPath: string;
181
+ /** Duration in milliseconds in which the lock is considered stale (default: 43_200_000) */
182
+ lockfileStaleDuration?: number;
183
+ } | null;
184
+ };
185
+
186
+ async function lockfile(
187
+ filepath: string,
188
+ staleDuration = 43_200_000
189
+ ): Promise<() => Promise<void>> {
190
+ let intervalId: ReturnType<typeof setInterval>;
191
+ const lockfilePath = `${filepath}.lock`;
192
+ const unlock = async () => {
193
+ clearInterval(intervalId);
194
+ try {
195
+ return await fs.promises.rmdir(lockfilePath);
196
+ } catch {
197
+ // ignore update errors
198
+ }
199
+ };
200
+ try {
201
+ await fs.promises.mkdir(lockfilePath);
202
+ // Set up an interval update for lockfile mtime so that if the lockfile is
203
+ // created by long running process (longer than staleDuration) we make sure
204
+ // that another process doesn't consider lockfile stale
205
+ intervalId = setInterval(() => {
206
+ const now = Date.now();
207
+ fs.promises.utimes(lockfilePath, now, now).catch(() => {});
208
+ }, staleDuration / 2);
209
+ intervalId.unref?.();
210
+ return unlock;
211
+ } catch (e) {
212
+ if ((e as any).code !== 'EEXIST') {
213
+ throw e;
214
+ }
215
+ const stats = await fs.promises.stat(lockfilePath);
216
+ // To make sure that the lockfile is not just a leftover from an unclean
217
+ // process exit, we check whether or not it is stale
218
+ if (Date.now() - stats.mtimeMs > staleDuration) {
219
+ await fs.promises.rmdir(lockfilePath);
220
+ return lockfile(filepath, staleDuration);
221
+ }
222
+ throw new Error(`File ${filepath} already locked`);
223
+ }
224
+ }
225
+
226
+ export class ThrottledAnalytics implements MongoshAnalytics {
227
+ private trackQueue = new Queue<AnalyticsTrackMessage>((message) => {
228
+ if (this.shouldEmitAnalyticsEvent()) {
229
+ this.target.track(message);
230
+ this.throttleState.count++;
231
+ }
232
+ });
233
+ private target: ThrottledAnalyticsOptions['target'] = new NoopAnalytics();
234
+ private currentUserId: string | null = null;
235
+ private throttleOptions: ThrottledAnalyticsOptions['throttle'] = null;
236
+ private throttleState = { count: 0, timestamp: Date.now() };
237
+ private restorePromise: Promise<void> = Promise.resolve();
238
+ private unlock: () => Promise<void> = () => Promise.resolve();
239
+
240
+ constructor({ target, throttle }: Partial<ThrottledAnalyticsOptions> = {}) {
241
+ this.target = target ?? this.target;
242
+ this.throttleOptions = throttle ?? this.throttleOptions;
243
+ }
244
+
245
+ get metadataPath() {
246
+ if (!this.throttleOptions) {
247
+ throw new Error(
248
+ 'Metadata path is not avaialble if throttling is disabled'
249
+ );
250
+ }
251
+
252
+ if (!this.currentUserId) {
253
+ throw new Error('Metadata path is not avaialble if userId is not set');
254
+ }
255
+
256
+ const {
257
+ throttleOptions: { metadataPath },
258
+ currentUserId: userId,
259
+ } = this;
260
+
261
+ return path.resolve(metadataPath, `am-${userId}.json`);
262
+ }
263
+
264
+ identify(message: AnalyticsIdentifyMessage): void {
265
+ if (this.currentUserId) {
266
+ throw new Error('Identify can only be called once per user session');
267
+ }
268
+ this.currentUserId = message.userId ?? message.anonymousId;
269
+ this.restorePromise = this.restoreThrottleState().then((enabled) => {
270
+ if (!enabled) {
271
+ this.trackQueue.disable();
272
+ return;
273
+ }
274
+ if (this.shouldEmitAnalyticsEvent()) {
275
+ this.target.identify(message);
276
+ this.throttleState.count++;
277
+ }
278
+ this.trackQueue.enable();
279
+ });
280
+ }
281
+
282
+ track(message: AnalyticsTrackMessage): void {
283
+ this.trackQueue.push(message);
284
+ }
285
+
286
+ // Tries to restore persisted throttle state and returns `true` if telemetry can
287
+ // be enabled on restore. This method must not throw exceptions, since there
288
+ // is nothing to handle them. If the error is unexpected, this method should
289
+ // return `false` to disable telemetry
290
+ private async restoreThrottleState(): Promise<boolean> {
291
+ if (!this.throttleOptions) {
292
+ return true;
293
+ }
294
+
295
+ if (!this.currentUserId) {
296
+ throw new Error('Trying to restore throttle state before userId is set');
297
+ }
298
+
299
+ try {
300
+ this.unlock = await lockfile(
301
+ this.metadataPath,
302
+ this.throttleOptions.lockfileStaleDuration
303
+ );
304
+ } catch (e) {
305
+ // Error while locking means that lock already exists or something
306
+ // unexpected happens, in either case we disable telemetry
307
+ return false;
308
+ }
309
+
310
+ try {
311
+ this.throttleState = JSON.parse(
312
+ await fs.promises.readFile(this.metadataPath, 'utf8')
313
+ );
314
+ } catch (e) {
315
+ if ((e as any).code !== 'ENOENT') {
316
+ // Any error except ENOENT means that we failed to restore state for
317
+ // some unknown / unexpected reason, ignore the error and assume that it
318
+ // is not safe to enable telemetry in that case
319
+ return false;
320
+ }
321
+ }
322
+
323
+ return true;
324
+ }
325
+
326
+ private shouldEmitAnalyticsEvent() {
327
+ // No throttle options indicate that throttling is disabled
328
+ if (!this.throttleOptions) {
329
+ return true;
330
+ }
331
+ // If throttle window passed, reset throttle state and allow to emit event
332
+ if (
333
+ Date.now() - this.throttleState.timestamp >
334
+ (this.throttleOptions.timeframe ?? 60_000)
335
+ ) {
336
+ this.throttleState.timestamp = Date.now();
337
+ this.throttleState.count = 0;
338
+ return true;
339
+ }
340
+ // Otherwise only allow if the count below the allowed rate
341
+ return this.throttleState.count < this.throttleOptions.rate;
342
+ }
343
+
344
+ flush(callback: (err?: Error | undefined) => void): void {
345
+ if (!this.throttleOptions) {
346
+ this.target.flush(callback);
347
+ return;
348
+ }
349
+
350
+ if (!this.currentUserId) {
351
+ callback(
352
+ new Error('Trying to persist throttle state before userId is set')
353
+ );
354
+ return;
355
+ }
356
+
357
+ this.restorePromise.finally(async () => {
358
+ try {
359
+ await fs.promises.writeFile(
360
+ this.metadataPath,
361
+ JSON.stringify(this.throttleState)
362
+ );
363
+ await this.unlock();
364
+ this.target.flush(callback);
365
+ } catch (e) {
366
+ callback(e as Error);
367
+ }
368
+ });
369
+ }
122
370
  }
package/src/index.ts CHANGED
@@ -1,2 +1,7 @@
1
1
  export { setupLoggerAndTelemetry } from './setup-logger-and-telemetry';
2
- export { MongoshAnalytics, ToggleableAnalytics, NoopAnalytics } from './analytics-helpers';
2
+ export {
3
+ MongoshAnalytics,
4
+ ToggleableAnalytics,
5
+ NoopAnalytics,
6
+ ThrottledAnalytics,
7
+ } from './analytics-helpers';