diff --git a/package-lock.json b/package-lock.json index adc48854e..07f83a6d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6337,6 +6337,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@mongodb-js/device-id": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/device-id/-/device-id-0.2.1.tgz", + "integrity": "sha512-kC/F1/ryJMNeIt+n7CATAf9AL/X5Nz1Tju8VseyViL2DF640dmF/JQwWmjakpsSTy5X9TVNOkG9ye4Mber8GHQ==", + "license": "Apache-2.0" + }, "node_modules/@mongodb-js/devtools-connect": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@mongodb-js/devtools-connect/-/devtools-connect-3.4.1.tgz", @@ -34575,6 +34581,7 @@ "version": "3.7.2", "license": "Apache-2.0", "dependencies": { + "@mongodb-js/device-id": "^0.2.1", "@mongodb-js/devtools-connect": "^3.4.1", "@mongosh/errors": "2.4.0", "@mongosh/history": "2.4.6", @@ -34587,6 +34594,7 @@ "@mongodb-js/eslint-config-mongosh": "^1.0.0", "@mongodb-js/prettier-config-devtools": "^1.0.1", "@mongodb-js/tsconfig-mongosh": "^1.0.0", + "@segment/analytics-node": "^1.3.0", "depcheck": "^1.4.7", "eslint": "^7.25.0", "prettier": "^2.8.8", diff --git a/packages/logging/package.json b/packages/logging/package.json index 4f5ec356a..ccaa1ce12 100644 --- a/packages/logging/package.json +++ b/packages/logging/package.json @@ -17,6 +17,7 @@ "node": ">=14.15.1" }, "dependencies": { + "@mongodb-js/device-id": "^0.2.1", "@mongodb-js/devtools-connect": "^3.4.1", "@mongosh/errors": "2.4.0", "@mongosh/history": "2.4.6", @@ -29,6 +30,7 @@ "@mongodb-js/eslint-config-mongosh": "^1.0.0", "@mongodb-js/prettier-config-devtools": "^1.0.1", "@mongodb-js/tsconfig-mongosh": "^1.0.0", + "@segment/analytics-node": "^1.3.0", "depcheck": "^1.4.7", "eslint": "^7.25.0", "prettier": "^2.8.8", diff --git a/packages/logging/src/logging-and-telemetry.spec.ts b/packages/logging/src/logging-and-telemetry.spec.ts index f06ff22f9..4675b2a20 100644 --- a/packages/logging/src/logging-and-telemetry.spec.ts +++ b/packages/logging/src/logging-and-telemetry.spec.ts @@ -8,9 +8,10 @@ import type { Writable } from 'stream'; import type { MongoshLoggingAndTelemetry } from '.'; import { setupLoggingAndTelemetry } from '.'; import type { LoggingAndTelemetry } from './logging-and-telemetry'; -import { getDeviceId } from './logging-and-telemetry'; import sinon from 'sinon'; import type { MongoshLoggingAndTelemetryArguments } from './types'; +import { getDeviceId } from '@mongodb-js/device-id'; +import { getMachineId } from 'native-machine-id'; describe('MongoshLoggingAndTelemetry', function () { let logOutput: any[]; @@ -253,6 +254,7 @@ describe('MongoshLoggingAndTelemetry', function () { }); it('automatically sets up device ID for telemetry', async function () { + const abortController = new AbortController(); const loggingAndTelemetry = setupLoggingAndTelemetry({ ...testLoggingArguments, bus, @@ -263,7 +265,10 @@ describe('MongoshLoggingAndTelemetry', function () { bus.emit('mongosh:new-user', { userId, anonymousId: userId }); - const deviceId = await getDeviceId(); + const deviceId = await getDeviceId({ + getMachineId: () => getMachineId({ raw: true }), + abortSignal: abortController.signal, + }); await (loggingAndTelemetry as LoggingAndTelemetry).setupTelemetryPromise; @@ -283,6 +288,50 @@ describe('MongoshLoggingAndTelemetry', function () { ]); }); + it('resolves device ID setup when flushed', async function () { + const loggingAndTelemetry = setupLoggingAndTelemetry({ + ...testLoggingArguments, + bus, + deviceId: undefined, + }); + sinon + // eslint-disable-next-line @typescript-eslint/no-var-requires + .stub(require('native-machine-id'), 'getMachineId') + .resolves( + new Promise((resolve) => setTimeout(resolve, 10_000).unref()) + ); + + loggingAndTelemetry.attachLogger(logger); + + // Start the device ID setup + const setupPromise = (loggingAndTelemetry as LoggingAndTelemetry) + .setupTelemetryPromise; + + // Flush before it completes + loggingAndTelemetry.flush(); + + // Emit an event that would trigger analytics + bus.emit('mongosh:new-user', { userId, anonymousId: userId }); + + await setupPromise; + + // Should still identify but with unknown device ID + expect(analyticsOutput).deep.equal([ + [ + 'identify', + { + anonymousId: userId, + traits: { + device_id: 'unknown', + platform: process.platform, + arch: process.arch, + session_id: logId, + }, + }, + ], + ]); + }); + it('only delays analytic outputs, not logging', async function () { // eslint-disable-next-line @typescript-eslint/no-empty-function let resolveTelemetry: (value: unknown) => void = () => {}; @@ -1184,13 +1233,4 @@ describe('MongoshLoggingAndTelemetry', function () { ], ]); }); - - describe('getDeviceId()', function () { - it('is consistent on the same machine', async function () { - const idA = await getDeviceId(); - const idB = await getDeviceId(); - - expect(idA).equals(idB); - }); - }); }); diff --git a/packages/logging/src/logging-and-telemetry.ts b/packages/logging/src/logging-and-telemetry.ts index b381c9972..28e339846 100644 --- a/packages/logging/src/logging-and-telemetry.ts +++ b/packages/logging/src/logging-and-telemetry.ts @@ -53,40 +53,7 @@ import type { MongoshLoggingAndTelemetryArguments, MongoshTrackingProperties, } from './types'; -import { createHmac } from 'crypto'; - -/** - * @returns A hashed, unique identifier for the running device or `"unknown"` if not known. - */ -export async function getDeviceId({ - onError, -}: { - onError?: (error: Error) => void; -} = {}): Promise { - try { - // Create a hashed format from the all uppercase version of the machine ID - // to match it exactly with the denisbrodbeck/machineid library that Atlas CLI uses. - const originalId: string = - // eslint-disable-next-line @typescript-eslint/no-var-requires - await require('native-machine-id').getMachineId({ - raw: true, - }); - - if (!originalId) { - return 'unknown'; - } - const hmac = createHmac('sha256', originalId); - - /** This matches the message used to create the hashes in Atlas CLI */ - const DEVICE_ID_HASH_MESSAGE = 'atlascli'; - - hmac.update(DEVICE_ID_HASH_MESSAGE); - return hmac.digest('hex'); - } catch (error) { - onError?.(error as Error); - return 'unknown'; - } -} +import { getDeviceId } from '@mongodb-js/device-id'; export function setupLoggingAndTelemetry( props: MongoshLoggingAndTelemetryArguments @@ -125,11 +92,11 @@ export class LoggingAndTelemetry implements MongoshLoggingAndTelemetry { private isBufferingTelemetryEvents = false; private deviceId: string | undefined; - /** @internal */ + + /** @internal Used for awaiting the telemetry setup in tests. */ public setupTelemetryPromise: Promise = Promise.resolve(); - // eslint-disable-next-line @typescript-eslint/no-empty-function - private resolveDeviceId: (value: string) => void = () => {}; + private readonly telemetrySetupAbort: AbortController = new AbortController(); constructor({ bus, @@ -160,26 +127,34 @@ export class LoggingAndTelemetry implements MongoshLoggingAndTelemetry { } public flush(): void { - // Run any telemetry events even if device ID hasn't been resolved yet - this.runAndClearPendingTelemetryEvents(); - // Run any other pending events with the set or dummy log for telemetry purposes. this.runAndClearPendingBusEvents(); - this.resolveDeviceId('unknown'); + // Abort setup, which will cause the device ID to be set to 'unknown' + // and run any remaining telemetry events + this.telemetrySetupAbort.abort(); } private async setupTelemetry(): Promise { if (!this.deviceId) { - this.deviceId = await Promise.race([ - getDeviceId({ - onError: (error) => - this.bus.emit('mongosh:error', error, 'telemetry'), - }), - new Promise((resolve) => { - this.resolveDeviceId = resolve; - }), - ]); + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const getMachineId = require('native-machine-id').getMachineId; + this.deviceId = await getDeviceId({ + getMachineId: () => getMachineId({ raw: true }), + onError: (reason, error) => { + if (reason === 'abort') { + return; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.bus.emit('mongosh:error', error, 'telemetry'); + }, + abortSignal: this.telemetrySetupAbort.signal, + }); + } catch (error) { + this.deviceId = 'unknown'; + this.bus.emit('mongosh:error', error as Error, 'telemetry'); + } } this.runAndClearPendingTelemetryEvents();