Skip to content

Commit 4702e00

Browse files
blvafmenezes
andauthored
feat: core telemetry functionality (#87)
Co-authored-by: Filipe Constantinov Menezes <[email protected]>
1 parent fa2fdd6 commit 4702e00

File tree

14 files changed

+437
-17
lines changed

14 files changed

+437
-17
lines changed

package-lock.json

Lines changed: 30 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"jest-environment-node": "^29.7.0",
4848
"jest-extended": "^4.0.2",
4949
"mongodb-runner": "^5.8.2",
50+
"native-machine-id": "^0.0.8",
5051
"openapi-types": "^12.1.3",
5152
"openapi-typescript": "^7.6.1",
5253
"prettier": "^3.5.3",
@@ -61,6 +62,7 @@
6162
"@mongodb-js/devtools-connect": "^3.7.2",
6263
"@mongosh/service-provider-node-driver": "^3.6.0",
6364
"bson": "^6.10.3",
65+
"lru-cache": "^11.1.0",
6466
"mongodb": "^6.15.0",
6567
"mongodb-log-writer": "^2.4.1",
6668
"mongodb-redact": "^1.1.6",

src/common/atlas/apiClient.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import createClient, { Client, FetchOptions, Middleware } from "openapi-fetch";
1+
import createClient, { Client, Middleware } from "openapi-fetch";
2+
import type { FetchOptions } from "openapi-fetch";
23
import { AccessToken, ClientCredentials } from "simple-oauth2";
34
import { ApiClientError } from "./apiClientError.js";
45
import { paths, operations } from "./openapi.js";
6+
import { BaseEvent } from "../../telemetry/types.js";
7+
import { mongoLogId } from "mongodb-log-writer";
8+
import logger from "../../logger.js";
59
import { packageInfo } from "../../packageInfo.js";
610

711
const ATLAS_API_VERSION = "2025-03-12";
@@ -93,6 +97,15 @@ export class ApiClient {
9397
this.client.use(this.errorMiddleware);
9498
}
9599

100+
public hasCredentials(): boolean {
101+
logger.info(
102+
mongoLogId(1_000_000),
103+
"api-client",
104+
`Checking if API client has credentials: ${!!(this.oauth2Client && this.accessToken)}`
105+
);
106+
return !!(this.oauth2Client && this.accessToken);
107+
}
108+
96109
public async getIpInfo(): Promise<{
97110
currentIpv4Address: string;
98111
}> {
@@ -118,6 +131,32 @@ export class ApiClient {
118131
}>;
119132
}
120133

134+
async sendEvents(events: BaseEvent[]): Promise<void> {
135+
let endpoint = "api/private/unauth/telemetry/events";
136+
const headers: Record<string, string> = {
137+
Accept: "application/json",
138+
"Content-Type": "application/json",
139+
"User-Agent": this.options.userAgent,
140+
};
141+
142+
const accessToken = await this.getAccessToken();
143+
if (accessToken) {
144+
endpoint = "api/private/v1.0/telemetry/events";
145+
headers["Authorization"] = `Bearer ${accessToken}`;
146+
}
147+
148+
const url = new URL(endpoint, this.options.baseUrl);
149+
const response = await fetch(url, {
150+
method: "POST",
151+
headers,
152+
body: JSON.stringify(events),
153+
});
154+
155+
if (!response.ok) {
156+
throw await ApiClientError.fromResponse(response);
157+
}
158+
}
159+
121160
// DO NOT EDIT. This is auto-generated code.
122161
async listClustersForAllProjects(options?: FetchOptions<operations["listClustersForAllProjects"]>) {
123162
const { data } = await this.client.GET("/api/atlas/v2/clusters", options);

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface UserConfig {
1010
apiBaseUrl?: string;
1111
apiClientId?: string;
1212
apiClientSecret?: string;
13+
telemetry?: "enabled" | "disabled";
1314
logPath: string;
1415
connectionString?: string;
1516
connectOptions: {

src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,5 @@ try {
3030
await server.connect(transport);
3131
} catch (error: unknown) {
3232
logger.emergency(mongoLogId(1_000_004), "server", `Fatal error running server: ${error as string}`);
33-
3433
process.exit(1);
3534
}

src/server.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { AtlasTools } from "./tools/atlas/tools.js";
55
import { MongoDbTools } from "./tools/mongodb/tools.js";
66
import logger, { initializeLogger } from "./logger.js";
77
import { mongoLogId } from "mongodb-log-writer";
8+
import { ObjectId } from "mongodb";
9+
import { Telemetry } from "./telemetry/telemetry.js";
810
import { UserConfig } from "./config.js";
911

1012
export interface ServerOptions {
@@ -16,25 +18,35 @@ export interface ServerOptions {
1618
export class Server {
1719
public readonly session: Session;
1820
private readonly mcpServer: McpServer;
21+
private readonly telemetry: Telemetry;
1922
private readonly userConfig: UserConfig;
2023

2124
constructor({ session, mcpServer, userConfig }: ServerOptions) {
2225
this.session = session;
26+
this.telemetry = new Telemetry(session);
2327
this.mcpServer = mcpServer;
2428
this.userConfig = userConfig;
2529
}
2630

2731
async connect(transport: Transport) {
2832
this.mcpServer.server.registerCapabilities({ logging: {} });
29-
3033
this.registerTools();
3134
this.registerResources();
3235

3336
await initializeLogger(this.mcpServer, this.userConfig.logPath);
3437

3538
await this.mcpServer.connect(transport);
3639

37-
logger.info(mongoLogId(1_000_004), "server", `Server started with transport ${transport.constructor.name}`);
40+
this.mcpServer.server.oninitialized = () => {
41+
this.session.setAgentRunner(this.mcpServer.server.getClientVersion());
42+
this.session.sessionId = new ObjectId().toString();
43+
44+
logger.info(
45+
mongoLogId(1_000_004),
46+
"server",
47+
`Server started with transport ${transport.constructor.name} and agent runner ${this.session.agentRunner?.name}`
48+
);
49+
};
3850
}
3951

4052
async close(): Promise<void> {
@@ -44,7 +56,7 @@ export class Server {
4456

4557
private registerTools() {
4658
for (const tool of [...AtlasTools, ...MongoDbTools]) {
47-
new tool(this.session, this.userConfig).register(this.mcpServer);
59+
new tool(this.session, this.userConfig, this.telemetry).register(this.mcpServer);
4860
}
4961
}
5062

src/session.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
22
import { ApiClient, ApiClientCredentials } from "./common/atlas/apiClient.js";
3+
import { Implementation } from "@modelcontextprotocol/sdk/types.js";
34

45
export interface SessionOptions {
56
apiBaseUrl?: string;
@@ -8,8 +9,13 @@ export interface SessionOptions {
89
}
910

1011
export class Session {
12+
sessionId?: string;
1113
serviceProvider?: NodeDriverServiceProvider;
1214
apiClient: ApiClient;
15+
agentRunner?: {
16+
name: string;
17+
version: string;
18+
};
1319

1420
constructor({ apiBaseUrl, apiClientId, apiClientSecret }: SessionOptions = {}) {
1521
const credentials: ApiClientCredentials | undefined =
@@ -26,6 +32,15 @@ export class Session {
2632
});
2733
}
2834

35+
setAgentRunner(agentRunner: Implementation | undefined) {
36+
if (agentRunner?.name && agentRunner?.version) {
37+
this.agentRunner = {
38+
name: agentRunner.name,
39+
version: agentRunner.version,
40+
};
41+
}
42+
}
43+
2944
async close(): Promise<void> {
3045
if (this.serviceProvider) {
3146
try {

src/telemetry/constants.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { getMachineIdSync } from "native-machine-id";
2+
import { packageInfo } from "../packageInfo.js";
3+
4+
/**
5+
* Machine-specific metadata formatted for telemetry
6+
*/
7+
export const MACHINE_METADATA = {
8+
device_id: getMachineIdSync(),
9+
mcp_server_version: packageInfo.version,
10+
mcp_server_name: packageInfo.mcpServerName,
11+
platform: process.platform,
12+
arch: process.arch,
13+
os_type: process.platform,
14+
os_version: process.version,
15+
} as const;

src/telemetry/eventCache.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { BaseEvent } from "./types.js";
2+
import { LRUCache } from "lru-cache";
3+
4+
/**
5+
* Singleton class for in-memory telemetry event caching
6+
* Provides a central storage for telemetry events that couldn't be sent
7+
* Uses LRU cache to automatically drop oldest events when limit is exceeded
8+
*/
9+
export class EventCache {
10+
private static instance: EventCache;
11+
private static readonly MAX_EVENTS = 1000;
12+
13+
private cache: LRUCache<number, BaseEvent>;
14+
private nextId = 0;
15+
16+
private constructor() {
17+
this.cache = new LRUCache({
18+
max: EventCache.MAX_EVENTS,
19+
// Using FIFO eviction strategy for events
20+
allowStale: false,
21+
updateAgeOnGet: false,
22+
});
23+
}
24+
25+
/**
26+
* Gets the singleton instance of EventCache
27+
* @returns The EventCache instance
28+
*/
29+
public static getInstance(): EventCache {
30+
if (!EventCache.instance) {
31+
EventCache.instance = new EventCache();
32+
}
33+
return EventCache.instance;
34+
}
35+
36+
/**
37+
* Gets a copy of the currently cached events
38+
* @returns Array of cached BaseEvent objects
39+
*/
40+
public getEvents(): BaseEvent[] {
41+
return Array.from(this.cache.values());
42+
}
43+
44+
/**
45+
* Appends new events to the cached events
46+
* LRU cache automatically handles dropping oldest events when limit is exceeded
47+
* @param events - The events to append
48+
*/
49+
public appendEvents(events: BaseEvent[]): void {
50+
for (const event of events) {
51+
this.cache.set(this.nextId++, event);
52+
}
53+
}
54+
55+
/**
56+
* Clears all cached events
57+
*/
58+
public clearEvents(): void {
59+
this.cache.clear();
60+
this.nextId = 0;
61+
}
62+
}

0 commit comments

Comments
 (0)