diff --git a/eslint.config.js b/eslint.config.js index e6dd1af0..e7059fc5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -48,7 +48,7 @@ export default defineConfig([ "coverage", "global.d.ts", "eslint.config.js", - "jest.config.ts", + "jest.config.cjs", "src/types/*.d.ts", ]), eslintPluginPrettierRecommended, diff --git a/jest.config.ts b/jest.config.cjs similarity index 97% rename from jest.config.ts rename to jest.config.cjs index 7fb7ce67..f9a34b53 100644 --- a/jest.config.ts +++ b/jest.config.cjs @@ -1,5 +1,5 @@ /** @type {import('ts-jest').JestConfigWithTsJest} **/ -export default { +module.exports = { preset: "ts-jest/presets/default-esm", testEnvironment: "node", extensionsToTreatAsEsm: [".ts"], diff --git a/package-lock.json b/package-lock.json index 9d01e564..4570a88e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.8.0", + "@mongodb-js/device-id": "^0.2.1", "@mongodb-js/devtools-connect": "^3.7.2", "@mongosh/service-provider-node-driver": "^3.6.0", "bson": "^6.10.3", @@ -2767,6 +2768,12 @@ "node": ">=16.20.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.7.2", "resolved": "https://registry.npmjs.org/@mongodb-js/devtools-connect/-/devtools-connect-3.7.2.tgz", diff --git a/package.json b/package.json index d8ce1f40..72576058 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.8.0", + "@mongodb-js/device-id": "^0.2.1", "@mongodb-js/devtools-connect": "^3.7.2", "@mongosh/service-provider-node-driver": "^3.6.0", "bson": "^6.10.3", diff --git a/src/helpers/deferred-promise.ts b/src/helpers/deferred-promise.ts deleted file mode 100644 index 1eb3f6e0..00000000 --- a/src/helpers/deferred-promise.ts +++ /dev/null @@ -1,58 +0,0 @@ -type DeferredPromiseOptions = { - timeout?: number; - onTimeout?: (resolve: (value: T) => void, reject: (reason: Error) => void) => void; -}; - -/** Creates a promise and exposes its resolve and reject methods, with an optional timeout. */ -export class DeferredPromise extends Promise { - resolve: (value: T) => void; - reject: (reason: unknown) => void; - private timeoutId?: NodeJS.Timeout; - - constructor( - executor: (resolve: (value: T) => void, reject: (reason: Error) => void) => void, - { timeout, onTimeout }: DeferredPromiseOptions = {} - ) { - let resolveFn: (value: T) => void; - let rejectFn: (reason?: unknown) => void; - - super((resolve, reject) => { - resolveFn = resolve; - rejectFn = reject; - }); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.resolve = resolveFn!; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.reject = rejectFn!; - - if (timeout !== undefined && onTimeout) { - this.timeoutId = setTimeout(() => { - onTimeout(this.resolve, this.reject); - }, timeout); - } - - executor( - (value: T) => { - if (this.timeoutId) clearTimeout(this.timeoutId); - this.resolve(value); - }, - (reason: Error) => { - if (this.timeoutId) clearTimeout(this.timeoutId); - this.reject(reason); - } - ); - } - - static fromPromise(promise: Promise, options: DeferredPromiseOptions = {}): DeferredPromise { - return new DeferredPromise((resolve, reject) => { - promise - .then((value) => { - resolve(value); - }) - .catch((reason) => { - reject(reason as Error); - }); - }, options); - } -} diff --git a/src/telemetry/telemetry.ts b/src/telemetry/telemetry.ts index 5f8554e6..ccf0eb41 100644 --- a/src/telemetry/telemetry.ts +++ b/src/telemetry/telemetry.ts @@ -5,9 +5,8 @@ import logger, { LogId } from "../logger.js"; import { ApiClient } from "../common/atlas/apiClient.js"; import { MACHINE_METADATA } from "./constants.js"; import { EventCache } from "./eventCache.js"; -import { createHmac } from "crypto"; import nodeMachineId from "node-machine-id"; -import { DeferredPromise } from "../helpers/deferred-promise.js"; +import { getDeviceId } from "@mongodb-js/device-id"; type EventResult = { success: boolean; @@ -19,7 +18,8 @@ export const DEVICE_ID_TIMEOUT = 3000; export class Telemetry { private isBufferingEvents: boolean = true; /** Resolves when the device ID is retrieved or timeout occurs */ - public deviceIdPromise: DeferredPromise | undefined; + public deviceIdPromise: Promise | undefined; + private deviceIdAbortController = new AbortController(); private eventCache: EventCache; private getRawMachineId: () => Promise; @@ -39,7 +39,6 @@ export class Telemetry { { commonProperties = { ...MACHINE_METADATA }, eventCache = EventCache.getInstance(), - getRawMachineId = () => nodeMachineId.machineId(true), }: { eventCache?: EventCache; @@ -57,50 +56,35 @@ export class Telemetry { if (!this.isTelemetryEnabled()) { return; } - this.deviceIdPromise = DeferredPromise.fromPromise(this.getDeviceId(), { - timeout: DEVICE_ID_TIMEOUT, - onTimeout: (resolve) => { - resolve("unknown"); - logger.debug(LogId.telemetryDeviceIdTimeout, "telemetry", "Device ID retrieval timed out"); + this.deviceIdPromise = getDeviceId({ + getMachineId: () => this.getRawMachineId(), + onError: (reason, error) => { + switch (reason) { + case "resolutionError": + logger.debug(LogId.telemetryDeviceIdFailure, "telemetry", String(error)); + break; + case "timeout": + logger.debug(LogId.telemetryDeviceIdTimeout, "telemetry", "Device ID retrieval timed out"); + break; + case "abort": + // No need to log in the case of aborts + break; + } }, + abortSignal: this.deviceIdAbortController.signal, }); + this.commonProperties.device_id = await this.deviceIdPromise; this.isBufferingEvents = false; } public async close(): Promise { - this.deviceIdPromise?.resolve("unknown"); + this.deviceIdAbortController.abort(); this.isBufferingEvents = false; await this.emitEvents(this.eventCache.getEvents()); } - /** - * @returns A hashed, unique identifier for the running device or `"unknown"` if not known. - */ - private async getDeviceId(): Promise { - try { - if (this.commonProperties.device_id) { - return this.commonProperties.device_id; - } - - const originalId: string = await this.getRawMachineId(); - - // 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 hmac = createHmac("sha256", originalId.toUpperCase()); - - /** 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) { - logger.debug(LogId.telemetryDeviceIdFailure, "telemetry", String(error)); - return "unknown"; - } - } - /** * Emits events through the telemetry pipeline * @param events - The events to emit diff --git a/tests/unit/deferred-promise.test.ts b/tests/unit/deferred-promise.test.ts deleted file mode 100644 index 5fdaba7d..00000000 --- a/tests/unit/deferred-promise.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { DeferredPromise } from "../../src/helpers/deferred-promise.js"; -import { jest } from "@jest/globals"; - -describe("DeferredPromise", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - afterEach(() => { - jest.useRealTimers(); - }); - - it("should resolve with the correct value", async () => { - const deferred = new DeferredPromise((resolve) => { - resolve("resolved value"); - }); - - await expect(deferred).resolves.toEqual("resolved value"); - }); - - it("should reject with the correct error", async () => { - const deferred = new DeferredPromise((_, reject) => { - reject(new Error("rejected error")); - }); - - await expect(deferred).rejects.toThrow("rejected error"); - }); - - it("should timeout if not resolved or rejected within the specified time", async () => { - const deferred = new DeferredPromise( - () => { - // Do not resolve or reject - }, - { timeout: 100, onTimeout: (resolve, reject) => reject(new Error("Promise timed out")) } - ); - - jest.advanceTimersByTime(100); - - await expect(deferred).rejects.toThrow("Promise timed out"); - }); - - it("should clear the timeout when resolved", async () => { - const deferred = new DeferredPromise( - (resolve) => { - setTimeout(() => resolve("resolved value"), 100); - }, - { timeout: 200 } - ); - - const promise = deferred.then((value) => { - expect(value).toBe("resolved value"); - }); - - jest.advanceTimersByTime(100); - await promise; - }); - - it("should clear the timeout when rejected", async () => { - const deferred = new DeferredPromise( - (_, reject) => { - setTimeout(() => reject(new Error("rejected error")), 100); - }, - { timeout: 200, onTimeout: (resolve, reject) => reject(new Error("Promise timed out")) } - ); - - const promise = deferred.catch((error) => { - expect(error).toEqual(new Error("rejected error")); - }); - - jest.advanceTimersByTime(100); - await promise; - }); -});