Skip to content

Commit 645196e

Browse files
authored
chore: defer machine ID resolution (#161)
1 parent 333c36a commit 645196e

17 files changed

+429
-109
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
"mongodb-log-writer": "^2.4.1",
7070
"mongodb-redact": "^1.1.6",
7171
"mongodb-schema": "^12.6.2",
72-
"node-machine-id": "^1.1.12",
72+
"node-machine-id": "1.1.12",
7373
"openapi-fetch": "^0.13.5",
7474
"simple-oauth2": "^5.1.0",
7575
"yargs-parser": "^21.1.1",

src/deferred-promise.ts

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

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { config } from "./config.js";
77
import { Session } from "./session.js";
88
import { Server } from "./server.js";
99
import { packageInfo } from "./packageInfo.js";
10+
import { Telemetry } from "./telemetry/telemetry.js";
1011

1112
try {
1213
const session = new Session({
@@ -19,9 +20,12 @@ try {
1920
version: packageInfo.version,
2021
});
2122

23+
const telemetry = Telemetry.create(session, config);
24+
2225
const server = new Server({
2326
mcpServer,
2427
session,
28+
telemetry,
2529
userConfig: config,
2630
});
2731

src/logger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const LogId = {
2020
telemetryEmitSuccess: mongoLogId(1_002_004),
2121
telemetryMetadataError: mongoLogId(1_002_005),
2222
telemetryDeviceIdFailure: mongoLogId(1_002_006),
23+
telemetryDeviceIdTimeout: mongoLogId(1_002_007),
2324

2425
toolExecute: mongoLogId(1_003_001),
2526
toolExecuteFailure: mongoLogId(1_003_002),

src/server.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface ServerOptions {
1616
session: Session;
1717
userConfig: UserConfig;
1818
mcpServer: McpServer;
19+
telemetry: Telemetry;
1920
}
2021

2122
export class Server {
@@ -25,10 +26,10 @@ export class Server {
2526
public readonly userConfig: UserConfig;
2627
private readonly startTime: number;
2728

28-
constructor({ session, mcpServer, userConfig }: ServerOptions) {
29+
constructor({ session, mcpServer, userConfig, telemetry }: ServerOptions) {
2930
this.startTime = Date.now();
3031
this.session = session;
31-
this.telemetry = new Telemetry(session, userConfig);
32+
this.telemetry = telemetry;
3233
this.mcpServer = mcpServer;
3334
this.userConfig = userConfig;
3435
}
@@ -93,6 +94,7 @@ export class Server {
9394
}
9495

9596
async close(): Promise<void> {
97+
await this.telemetry.close();
9698
await this.session.close();
9799
await this.mcpServer.close();
98100
}

src/telemetry/constants.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { packageInfo } from "../packageInfo.js";
22
import { type CommonStaticProperties } from "./types.js";
3-
import { getDeviceId } from "./device-id.js";
3+
44
/**
55
* Machine-specific metadata formatted for telemetry
66
*/
77
export const MACHINE_METADATA: CommonStaticProperties = {
8-
device_id: getDeviceId(),
98
mcp_server_version: packageInfo.version,
109
mcp_server_name: packageInfo.mcpServerName,
1110
platform: process.platform,

src/telemetry/device-id.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

src/telemetry/eventCache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { BaseEvent } from "./types.js";
21
import { LRUCache } from "lru-cache";
2+
import { BaseEvent } from "./types.js";
33

44
/**
55
* Singleton class for in-memory telemetry event caching

src/telemetry/telemetry.ts

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,101 @@ import logger, { LogId } from "../logger.js";
55
import { ApiClient } from "../common/atlas/apiClient.js";
66
import { MACHINE_METADATA } from "./constants.js";
77
import { EventCache } from "./eventCache.js";
8+
import { createHmac } from "crypto";
9+
import nodeMachineId from "node-machine-id";
10+
import { DeferredPromise } from "../deferred-promise.js";
811

912
type EventResult = {
1013
success: boolean;
1114
error?: Error;
1215
};
1316

17+
export const DEVICE_ID_TIMEOUT = 3000;
18+
1419
export class Telemetry {
15-
private readonly commonProperties: CommonProperties;
20+
private isBufferingEvents: boolean = true;
21+
/** Resolves when the device ID is retrieved or timeout occurs */
22+
public deviceIdPromise: DeferredPromise<string> | undefined;
23+
private eventCache: EventCache;
24+
private getRawMachineId: () => Promise<string>;
1625

17-
constructor(
26+
private constructor(
1827
private readonly session: Session,
1928
private readonly userConfig: UserConfig,
20-
private readonly eventCache: EventCache = EventCache.getInstance()
29+
private readonly commonProperties: CommonProperties,
30+
{ eventCache, getRawMachineId }: { eventCache: EventCache; getRawMachineId: () => Promise<string> }
2131
) {
22-
this.commonProperties = {
23-
...MACHINE_METADATA,
24-
};
32+
this.eventCache = eventCache;
33+
this.getRawMachineId = getRawMachineId;
34+
}
35+
36+
static create(
37+
session: Session,
38+
userConfig: UserConfig,
39+
{
40+
commonProperties = { ...MACHINE_METADATA },
41+
eventCache = EventCache.getInstance(),
42+
43+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
44+
getRawMachineId = () => nodeMachineId.machineId(true),
45+
}: {
46+
eventCache?: EventCache;
47+
getRawMachineId?: () => Promise<string>;
48+
commonProperties?: CommonProperties;
49+
} = {}
50+
): Telemetry {
51+
const instance = new Telemetry(session, userConfig, commonProperties, { eventCache, getRawMachineId });
52+
53+
void instance.start();
54+
return instance;
55+
}
56+
57+
private async start(): Promise<void> {
58+
if (!this.isTelemetryEnabled()) {
59+
return;
60+
}
61+
this.deviceIdPromise = DeferredPromise.fromPromise(this.getDeviceId(), {
62+
timeout: DEVICE_ID_TIMEOUT,
63+
onTimeout: (resolve) => {
64+
resolve("unknown");
65+
logger.debug(LogId.telemetryDeviceIdTimeout, "telemetry", "Device ID retrieval timed out");
66+
},
67+
});
68+
this.commonProperties.device_id = await this.deviceIdPromise;
69+
70+
this.isBufferingEvents = false;
71+
}
72+
73+
public async close(): Promise<void> {
74+
this.deviceIdPromise?.resolve("unknown");
75+
this.isBufferingEvents = false;
76+
await this.emitEvents(this.eventCache.getEvents());
77+
}
78+
79+
/**
80+
* @returns A hashed, unique identifier for the running device or `"unknown"` if not known.
81+
*/
82+
private async getDeviceId(): Promise<string> {
83+
try {
84+
if (this.commonProperties.device_id) {
85+
return this.commonProperties.device_id;
86+
}
87+
88+
const originalId: string = await this.getRawMachineId();
89+
90+
// Create a hashed format from the all uppercase version of the machine ID
91+
// to match it exactly with the denisbrodbeck/machineid library that Atlas CLI uses.
92+
const hmac = createHmac("sha256", originalId.toUpperCase());
93+
94+
/** This matches the message used to create the hashes in Atlas CLI */
95+
const DEVICE_ID_HASH_MESSAGE = "atlascli";
96+
97+
hmac.update(DEVICE_ID_HASH_MESSAGE);
98+
return hmac.digest("hex");
99+
} catch (error) {
100+
logger.debug(LogId.telemetryDeviceIdFailure, "telemetry", String(error));
101+
return "unknown";
102+
}
25103
}
26104

27105
/**
@@ -78,6 +156,11 @@ export class Telemetry {
78156
* Falls back to caching if both attempts fail
79157
*/
80158
private async emit(events: BaseEvent[]): Promise<void> {
159+
if (this.isBufferingEvents) {
160+
this.eventCache.appendEvents(events);
161+
return;
162+
}
163+
81164
const cachedEvents = this.eventCache.getEvents();
82165
const allEvents = [...cachedEvents, ...events];
83166

src/telemetry/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ export type ServerEvent = TelemetryEvent<ServerEventProperties>;
5353
* Interface for static properties, they can be fetched once and reused.
5454
*/
5555
export type CommonStaticProperties = {
56-
device_id?: string;
5756
mcp_server_version: string;
5857
mcp_server_name: string;
5958
platform: string;

tests/integration/helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { UserConfig } from "../../src/config.js";
55
import { McpError } from "@modelcontextprotocol/sdk/types.js";
66
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
77
import { Session } from "../../src/session.js";
8+
import { Telemetry } from "../../src/telemetry/telemetry.js";
89
import { config } from "../../src/config.js";
910

1011
interface ParameterInfo {
@@ -56,9 +57,14 @@ export function setupIntegrationTest(getUserConfig: () => UserConfig): Integrati
5657
apiClientSecret: userConfig.apiClientSecret,
5758
});
5859

60+
userConfig.telemetry = "disabled";
61+
62+
const telemetry = Telemetry.create(session, userConfig);
63+
5964
mcpServer = new Server({
6065
session,
6166
userConfig,
67+
telemetry,
6268
mcpServer: new McpServer({
6369
name: "test-server",
6470
version: "5.2.3",

tests/integration/telemetry.test.ts

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

tests/integration/tools/mongodb/mongodbHelpers.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,6 @@ export function setupMongoDBIntegrationTest(): MongoDBIntegrationTest {
7676
let dbsDir = path.join(tmpDir, "mongodb-runner", "dbs");
7777
for (let i = 0; i < 10; i++) {
7878
try {
79-
// TODO: Fix this type once mongodb-runner is updated.
80-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
8179
mongoCluster = await MongoCluster.start({
8280
tmpDir: dbsDir,
8381
logDir: path.join(tmpDir, "mongodb-runner", "logs"),

0 commit comments

Comments
 (0)