@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.
- package/.depcheckrc +11 -2
- package/.eslintrc.js +10 -1
- package/.prettierignore +6 -0
- package/.prettierrc.json +1 -0
- package/README.md +1 -1
- package/lib/analytics-helpers.d.ts +59 -15
- package/lib/analytics-helpers.js +203 -36
- package/lib/analytics-helpers.js.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.map +1 -1
- package/lib/setup-logger-and-telemetry.d.ts +2 -2
- package/lib/setup-logger-and-telemetry.js +35 -34
- package/lib/setup-logger-and-telemetry.js.map +1 -1
- package/package.json +20 -11
- package/src/analytics-helpers.spec.ts +204 -33
- package/src/analytics-helpers.ts +301 -53
- package/src/index.ts +6 -1
- package/src/setup-logger-and-telemetry.spec.ts +306 -116
- package/src/setup-logger-and-telemetry.ts +465 -194
- package/tsconfig-lint.json +5 -0
- package/tsconfig.json +3 -7
- package/tsconfig.lint.json +0 -8
package/src/analytics-helpers.ts
CHANGED
|
@@ -1,25 +1,76 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 {}
|
|
32
|
-
track(_info: any): void {}
|
|
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
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
109
|
-
|
|
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.
|
|
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 {
|
|
2
|
+
export {
|
|
3
|
+
MongoshAnalytics,
|
|
4
|
+
ToggleableAnalytics,
|
|
5
|
+
NoopAnalytics,
|
|
6
|
+
ThrottledAnalytics,
|
|
7
|
+
} from './analytics-helpers';
|