Skip to content

Commit 9022a61

Browse files
committed
fix: use non-native dependency and defer machine ID resolution
1 parent 80e71af commit 9022a61

File tree

5 files changed

+328
-97
lines changed

5 files changed

+328
-97
lines changed

src/deferred-promise.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
type DeferredPromiseOptions = {
2+
timeout?: number;
3+
};
4+
5+
/** Creates a promise and exposes its resolve and reject methods, with an optional timeout. */
6+
export class DeferredPromise<T> extends Promise<T> {
7+
resolve!: (value: T) => void;
8+
reject!: (reason: unknown) => void;
9+
private timeoutId?: NodeJS.Timeout;
10+
11+
constructor(resolver: (resolve: (value: T) => void, reject: (reason: Error) => void) => void, timeout?: number) {
12+
let resolveFn: (value: T) => void;
13+
let rejectFn: (reason?: unknown) => void;
14+
15+
super((resolve, reject) => {
16+
resolveFn = resolve;
17+
rejectFn = reject;
18+
});
19+
20+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
21+
this.resolve = resolveFn!;
22+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
23+
this.reject = rejectFn!;
24+
25+
if (timeout !== undefined) {
26+
this.timeoutId = setTimeout(() => {
27+
this.reject(new Error("Promise timed out"));
28+
}, timeout);
29+
}
30+
31+
if (resolver) {
32+
resolver(
33+
(value: T) => {
34+
if (this.timeoutId) clearTimeout(this.timeoutId);
35+
this.resolve(value);
36+
},
37+
(reason: Error) => {
38+
if (this.timeoutId) clearTimeout(this.timeoutId);
39+
this.reject(reason);
40+
}
41+
);
42+
}
43+
}
44+
45+
static fromPromise<T>(promise: Promise<T>, options: DeferredPromiseOptions = {}): DeferredPromise<T> {
46+
return new DeferredPromise<T>((resolve, reject) => {
47+
promise
48+
.then((value) => {
49+
resolve(value);
50+
})
51+
.catch((reason) => {
52+
reject(reason as Error);
53+
});
54+
}, options.timeout);
55+
}
56+
}

src/telemetry/telemetry.ts

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ import { MACHINE_METADATA } from "./constants.js";
77
import { EventCache } from "./eventCache.js";
88
import { createHmac } from "crypto";
99
import { machineId } from "node-machine-id";
10+
import { DeferredPromise } from "../deferred-promise.js";
1011

1112
type EventResult = {
1213
success: boolean;
1314
error?: Error;
1415
};
1516

17+
export const DEVICE_ID_TIMEOUT = 3000;
18+
1619
export class Telemetry {
1720
private isBufferingEvents: boolean = true;
18-
private resolveDeviceId: (deviceId: string) => void = () => {};
21+
/** Resolves when the device ID is retrieved or timeout occurs */
22+
public deviceIdPromise: DeferredPromise<string> | undefined;
1923

2024
private constructor(
2125
private readonly session: Session,
@@ -25,7 +29,7 @@ export class Telemetry {
2529

2630
static create(
2731
session: Session,
28-
commonProperties: CommonProperties = MACHINE_METADATA,
32+
commonProperties: CommonProperties = { ...MACHINE_METADATA },
2933
eventCache: EventCache = EventCache.getInstance()
3034
): Telemetry {
3135
const instance = new Telemetry(session, commonProperties, eventCache);
@@ -35,55 +39,42 @@ export class Telemetry {
3539
}
3640

3741
private async start(): Promise<void> {
38-
this.commonProperties.device_id = await this.getDeviceId();
42+
this.deviceIdPromise = DeferredPromise.fromPromise(this.getDeviceId());
43+
this.commonProperties.device_id = await this.deviceIdPromise;
3944

4045
this.isBufferingEvents = false;
41-
await this.emitEvents(this.eventCache.getEvents());
4246
}
4347

4448
public async close(): Promise<void> {
45-
this.resolveDeviceId("unknown");
49+
this.deviceIdPromise?.resolve("unknown");
4650
this.isBufferingEvents = false;
4751
await this.emitEvents(this.eventCache.getEvents());
4852
}
4953

50-
private async machineIdWithTimeout(): Promise<string> {
51-
try {
52-
return Promise.race<string>([
53-
machineId(true),
54-
new Promise<string>((resolve, reject) => {
55-
this.resolveDeviceId = resolve;
56-
setTimeout(() => {
57-
reject(new Error("Timeout getting machine ID"));
58-
}, 3000);
59-
}),
60-
]);
61-
} catch (error) {
62-
logger.debug(LogId.telemetryMachineIdFailure, "telemetry", `Error getting machine ID: ${String(error)}`);
63-
return "unknown";
64-
}
65-
}
66-
6754
/**
6855
* @returns A hashed, unique identifier for the running device or `undefined` if not known.
6956
*/
7057
private async getDeviceId(): Promise<string> {
71-
if (this.commonProperties.device_id) {
72-
return this.commonProperties.device_id;
73-
}
74-
75-
// Create a hashed format from the all uppercase version of the machine ID
76-
// to match it exactly with the denisbrodbeck/machineid library that Atlas CLI uses.
58+
try {
59+
if (this.commonProperties.device_id) {
60+
return this.commonProperties.device_id;
61+
}
7762

78-
const originalId = (await this.machineIdWithTimeout()).toUpperCase();
63+
const originalId = await DeferredPromise.fromPromise(machineId(true), { timeout: DEVICE_ID_TIMEOUT });
7964

80-
const hmac = createHmac("sha256", originalId);
65+
// Create a hashed format from the all uppercase version of the machine ID
66+
// to match it exactly with the denisbrodbeck/machineid library that Atlas CLI uses.
67+
const hmac = createHmac("sha256", originalId.toUpperCase());
8168

82-
/** This matches the message used to create the hashes in Atlas CLI */
83-
const DEVICE_ID_HASH_MESSAGE = "atlascli";
69+
/** This matches the message used to create the hashes in Atlas CLI */
70+
const DEVICE_ID_HASH_MESSAGE = "atlascli";
8471

85-
hmac.update(DEVICE_ID_HASH_MESSAGE);
86-
return hmac.digest("hex");
72+
hmac.update(DEVICE_ID_HASH_MESSAGE);
73+
return hmac.digest("hex");
74+
} catch (error) {
75+
logger.debug(LogId.telemetryMachineIdFailure, "telemetry", String(error));
76+
return "unknown";
77+
}
8778
}
8879

8980
/**
@@ -134,6 +125,7 @@ export class Telemetry {
134125
public getCommonProperties(): CommonProperties {
135126
return {
136127
...this.commonProperties,
128+
device_id: this.commonProperties.device_id,
137129
mcp_client_version: this.session.agentRunner?.version,
138130
mcp_client_name: this.session.agentRunner?.name,
139131
session_id: this.session.sessionId,

tests/integration/telemetry.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { createHmac } from "crypto";
2+
import { machineId } from "node-machine-id";
3+
import { Telemetry } from "../../src/telemetry/telemetry.js";
4+
import { Session } from "../../src/session.js";
5+
6+
describe("Telemetry", () => {
7+
it("should resolve the actual machine ID", async () => {
8+
const actualId = await machineId(true);
9+
// Should be a UUID
10+
expect(actualId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
11+
const actualHashedId = createHmac("sha256", actualId.toUpperCase()).update("atlascli").digest("hex");
12+
13+
const telemetry = Telemetry.create(
14+
new Session({
15+
apiBaseUrl: "",
16+
})
17+
);
18+
19+
expect(telemetry.getCommonProperties().device_id).toBe(undefined);
20+
expect(telemetry["isBufferingEvents"]).toBe(true);
21+
22+
await telemetry.deviceIdPromise;
23+
24+
expect(telemetry.getCommonProperties().device_id).toBe(actualHashedId);
25+
expect(telemetry["isBufferingEvents"]).toBe(false);
26+
});
27+
});

tests/unit/deferred-promise.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { DeferredPromise } from "../../src/deferred-promise.js";
2+
3+
describe("DeferredPromise", () => {
4+
it("should resolve with the correct value", async () => {
5+
const deferred = new DeferredPromise<string>((resolve) => {
6+
resolve("resolved value");
7+
});
8+
9+
await expect(deferred).resolves.toEqual("resolved value");
10+
});
11+
12+
it("should reject with the correct error", async () => {
13+
const deferred = new DeferredPromise<string>((_, reject) => {
14+
reject(new Error("rejected error"));
15+
});
16+
17+
await expect(deferred).rejects.toThrow("rejected error");
18+
});
19+
20+
it("should timeout if not resolved or rejected within the specified time", async () => {
21+
const deferred = new DeferredPromise<string>(() => {
22+
// Do not resolve or reject
23+
}, 10);
24+
25+
await expect(deferred).rejects.toThrow("Promise timed out");
26+
});
27+
28+
it("should clear the timeout when resolved", async () => {
29+
jest.useFakeTimers();
30+
31+
const deferred = new DeferredPromise<string>((resolve) => {
32+
setTimeout(() => resolve("resolved value"), 100);
33+
}, 200);
34+
35+
const promise = deferred.then((value) => {
36+
expect(value).toBe("resolved value");
37+
});
38+
39+
jest.advanceTimersByTime(100);
40+
await promise;
41+
42+
jest.useRealTimers();
43+
});
44+
45+
it("should clear the timeout when rejected", async () => {
46+
jest.useFakeTimers();
47+
48+
const deferred = new DeferredPromise<string>((_, reject) => {
49+
setTimeout(() => reject(new Error("rejected error")), 100);
50+
}, 200);
51+
52+
const promise = deferred.catch((error) => {
53+
expect(error).toEqual(new Error("rejected error"));
54+
});
55+
56+
jest.advanceTimersByTime(100);
57+
await promise;
58+
59+
jest.useRealTimers();
60+
});
61+
});

0 commit comments

Comments
 (0)