|
1 | 1 | import { getDeviceId } from "@mongodb-js/device-id"; |
2 | | -import nodeMachineId from "node-machine-id"; |
3 | | -import { LogId, CompositeLogger } from "../common/logger.js"; |
| 2 | +import { LogId, LoggerBase } from "../common/logger.js"; |
4 | 3 |
|
5 | 4 | export const DEVICE_ID_TIMEOUT = 3000; |
6 | 5 |
|
7 | 6 | /** |
8 | | - * Retrieves the device ID for telemetry purposes. |
9 | | - * The device ID is generated using the machine ID and additional logic to handle errors. |
10 | | - * |
11 | | - * @returns Promise that resolves to the device ID string |
12 | | - * If an error occurs during retrieval, the function returns "unknown". |
13 | | - * |
14 | | - * @example |
15 | | - * ```typescript |
16 | | - * const deviceId = await getDeviceIdForConnection(logger); |
17 | | - * console.log(deviceId); // Outputs the device ID or "unknown" in case of failure |
18 | | - * ``` |
| 7 | + * Singleton class for managing device ID retrieval and caching. |
| 8 | + * Starts device ID calculation early and is shared across all services. |
19 | 9 | */ |
20 | | -export async function getDeviceIdForConnection(logger: CompositeLogger): Promise<string> { |
21 | | - const controller = new AbortController(); |
22 | | - |
23 | | - try { |
24 | | - const deviceId = await getDeviceId({ |
25 | | - getMachineId: () => nodeMachineId.machineId(true), |
26 | | - onError: (reason, error) => { |
27 | | - switch (reason) { |
28 | | - case "resolutionError": |
29 | | - logger.debug(LogId.telemetryDeviceIdFailure, "deviceId", String(error)); |
30 | | - break; |
31 | | - case "timeout": |
32 | | - logger.debug(LogId.telemetryDeviceIdTimeout, "deviceId", "Device ID retrieval timed out"); |
33 | | - break; |
34 | | - case "abort": |
35 | | - // No need to log in the case of aborts |
36 | | - break; |
37 | | - } |
38 | | - }, |
39 | | - abortSignal: controller.signal, |
40 | | - }); |
41 | | - return deviceId; |
42 | | - } catch (error) { |
43 | | - logger.debug(LogId.telemetryDeviceIdFailure, "deviceId", `Failed to get device ID: ${String(error)}`); |
44 | | - return "unknown"; |
| 10 | +export class DeviceIdService { |
| 11 | + private static instance: DeviceIdService | undefined = undefined; |
| 12 | + private deviceId: string | undefined = undefined; |
| 13 | + private deviceIdPromise: Promise<string> | undefined = undefined; |
| 14 | + private abortController: AbortController | undefined = undefined; |
| 15 | + private logger: LoggerBase; |
| 16 | + private getMachineId: () => Promise<string>; |
| 17 | + |
| 18 | + private constructor(logger: LoggerBase, getMachineId: () => Promise<string>) { |
| 19 | + this.logger = logger; |
| 20 | + this.getMachineId = getMachineId; |
| 21 | + // Start device ID calculation immediately |
| 22 | + this.startDeviceIdCalculation(); |
| 23 | + } |
| 24 | + |
| 25 | + /** |
| 26 | + * Initializes the DeviceIdService singleton with a logger. |
| 27 | + * A separated init method is used to use a single instance of the logger. |
| 28 | + * @param logger - The logger instance to use |
| 29 | + * @returns The DeviceIdService instance |
| 30 | + */ |
| 31 | + public static init(logger: LoggerBase, getMachineId: () => Promise<string>): DeviceIdService { |
| 32 | + if (DeviceIdService.instance) { |
| 33 | + return DeviceIdService.instance; |
| 34 | + } |
| 35 | + DeviceIdService.instance = new DeviceIdService(logger, getMachineId); |
| 36 | + return DeviceIdService.instance; |
| 37 | + } |
| 38 | + |
| 39 | + /** |
| 40 | + * Gets the singleton instance of DeviceIdService. |
| 41 | + * @returns The DeviceIdService instance |
| 42 | + */ |
| 43 | + public static getInstance(): DeviceIdService { |
| 44 | + if (!DeviceIdService.instance) { |
| 45 | + throw Error("DeviceIdService not initialized"); |
| 46 | + } |
| 47 | + return DeviceIdService.instance; |
| 48 | + } |
| 49 | + |
| 50 | + /** |
| 51 | + * Resets the singleton instance (mainly for testing). |
| 52 | + */ |
| 53 | + static resetInstance(): void { |
| 54 | + DeviceIdService.instance = undefined; |
| 55 | + } |
| 56 | + |
| 57 | + /** |
| 58 | + * Starts the device ID calculation process. |
| 59 | + * This method is called automatically in the constructor. |
| 60 | + */ |
| 61 | + private startDeviceIdCalculation(): void { |
| 62 | + if (this.deviceIdPromise) { |
| 63 | + return; |
| 64 | + } |
| 65 | + |
| 66 | + this.abortController = new AbortController(); |
| 67 | + this.deviceIdPromise = this.calculateDeviceId(); |
| 68 | + } |
| 69 | + |
| 70 | + /** |
| 71 | + * Gets the device ID, waiting for the calculation to complete if necessary. |
| 72 | + * @returns Promise that resolves to the device ID string |
| 73 | + */ |
| 74 | + public async getDeviceId(): Promise<string> { |
| 75 | + // Return cached value if available |
| 76 | + if (this.deviceId !== undefined) { |
| 77 | + return this.deviceId; |
| 78 | + } |
| 79 | + |
| 80 | + // If calculation is already in progress, wait for it |
| 81 | + if (this.deviceIdPromise) { |
| 82 | + return this.deviceIdPromise; |
| 83 | + } |
| 84 | + |
| 85 | + // If somehow we don't have a promise, raise an error |
| 86 | + throw new Error("Failed to get device ID"); |
| 87 | + } |
| 88 | + /** |
| 89 | + * Aborts any ongoing device ID calculation. |
| 90 | + */ |
| 91 | + abortCalculation(): void { |
| 92 | + if (this.abortController) { |
| 93 | + this.abortController.abort(); |
| 94 | + this.abortController = undefined; |
| 95 | + } |
| 96 | + this.deviceIdPromise = undefined; |
| 97 | + } |
| 98 | + |
| 99 | + /** |
| 100 | + * Internal method that performs the actual device ID calculation. |
| 101 | + */ |
| 102 | + private async calculateDeviceId(): Promise<string> { |
| 103 | + if (!this.abortController) { |
| 104 | + throw new Error("Device ID calculation not started"); |
| 105 | + } |
| 106 | + |
| 107 | + try { |
| 108 | + const deviceId = await getDeviceId({ |
| 109 | + getMachineId: this.getMachineId, |
| 110 | + onError: (reason, error) => { |
| 111 | + switch (reason) { |
| 112 | + case "resolutionError": |
| 113 | + this.logger.debug({ |
| 114 | + id: LogId.telemetryDeviceIdFailure, |
| 115 | + context: "deviceId", |
| 116 | + message: `Device ID resolution error: ${String(error)}`, |
| 117 | + }); |
| 118 | + break; |
| 119 | + case "timeout": |
| 120 | + this.logger.debug({ |
| 121 | + id: LogId.telemetryDeviceIdTimeout, |
| 122 | + context: "deviceId", |
| 123 | + message: "Device ID retrieval timed out", |
| 124 | + }); |
| 125 | + break; |
| 126 | + case "abort": |
| 127 | + // No need to log in the case of aborts |
| 128 | + break; |
| 129 | + } |
| 130 | + }, |
| 131 | + abortSignal: this.abortController.signal, |
| 132 | + }); |
| 133 | + |
| 134 | + // Cache the result |
| 135 | + this.deviceId = deviceId; |
| 136 | + return deviceId; |
| 137 | + } catch (error) { |
| 138 | + // Check if this was an abort error |
| 139 | + if (error instanceof Error && error.name === "AbortError") { |
| 140 | + throw error; // Re-throw abort errors |
| 141 | + } |
| 142 | + |
| 143 | + this.logger.debug({ |
| 144 | + id: LogId.telemetryDeviceIdFailure, |
| 145 | + context: "deviceId", |
| 146 | + message: `Failed to get device ID: ${String(error)}`, |
| 147 | + }); |
| 148 | + |
| 149 | + // Cache the fallback value |
| 150 | + this.deviceId = "unknown"; |
| 151 | + return "unknown"; |
| 152 | + } finally { |
| 153 | + this.abortController = undefined; |
| 154 | + } |
45 | 155 | } |
46 | 156 | } |
0 commit comments