diff --git a/package-lock.json b/package-lock.json index 751318be..25f3c06b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@mongodb-js/devtools-connect": "^3.7.2", "@mongosh/service-provider-node-driver": "^3.6.0", "bson": "^6.10.3", + "lru-cache": "^11.1.0", "mongodb": "^6.15.0", "mongodb-log-writer": "^2.4.1", "mongodb-redact": "^1.1.6", @@ -41,6 +42,7 @@ "jest-environment-node": "^29.7.0", "jest-extended": "^4.0.2", "mongodb-runner": "^5.8.2", + "native-machine-id": "^0.0.8", "openapi-types": "^12.1.3", "openapi-typescript": "^7.6.1", "prettier": "^3.5.3", @@ -6161,8 +6163,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -8417,8 +8419,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/filelist": { "version": "1.0.4", @@ -11100,6 +11102,31 @@ "license": "MIT", "optional": true }, + "node_modules/native-machine-id": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/native-machine-id/-/native-machine-id-0.0.8.tgz", + "integrity": "sha512-0sMw6WHfG1A7N59C1odmge9K/F9uC+1dgXHjMW57w319ii/nI05FDFwlXSjPMAHHB7hU7OInpVuH+Sgjz5enog==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^8.0.0" + }, + "bin": { + "native-machine-id": "dist/bin/machine-id.js" + } + }, + "node_modules/native-machine-id/node_modules/node-addon-api": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.1.tgz", + "integrity": "sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", diff --git a/package.json b/package.json index ca9d20d0..5c211101 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "jest-environment-node": "^29.7.0", "jest-extended": "^4.0.2", "mongodb-runner": "^5.8.2", + "native-machine-id": "^0.0.8", "openapi-types": "^12.1.3", "openapi-typescript": "^7.6.1", "prettier": "^3.5.3", @@ -61,6 +62,7 @@ "@mongodb-js/devtools-connect": "^3.7.2", "@mongosh/service-provider-node-driver": "^3.6.0", "bson": "^6.10.3", + "lru-cache": "^11.1.0", "mongodb": "^6.15.0", "mongodb-log-writer": "^2.4.1", "mongodb-redact": "^1.1.6", diff --git a/src/common/atlas/apiClient.ts b/src/common/atlas/apiClient.ts index 56be6077..f71e1162 100644 --- a/src/common/atlas/apiClient.ts +++ b/src/common/atlas/apiClient.ts @@ -1,7 +1,11 @@ -import createClient, { Client, FetchOptions, Middleware } from "openapi-fetch"; +import createClient, { Client, Middleware } from "openapi-fetch"; +import type { FetchOptions } from "openapi-fetch"; import { AccessToken, ClientCredentials } from "simple-oauth2"; import { ApiClientError } from "./apiClientError.js"; import { paths, operations } from "./openapi.js"; +import { BaseEvent } from "../../telemetry/types.js"; +import { mongoLogId } from "mongodb-log-writer"; +import logger from "../../logger.js"; import { packageInfo } from "../../packageInfo.js"; const ATLAS_API_VERSION = "2025-03-12"; @@ -93,6 +97,15 @@ export class ApiClient { this.client.use(this.errorMiddleware); } + public hasCredentials(): boolean { + logger.info( + mongoLogId(1_000_000), + "api-client", + `Checking if API client has credentials: ${!!(this.oauth2Client && this.accessToken)}` + ); + return !!(this.oauth2Client && this.accessToken); + } + public async getIpInfo(): Promise<{ currentIpv4Address: string; }> { @@ -118,6 +131,32 @@ export class ApiClient { }>; } + async sendEvents(events: BaseEvent[]): Promise { + let endpoint = "api/private/unauth/telemetry/events"; + const headers: Record = { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": this.options.userAgent, + }; + + const accessToken = await this.getAccessToken(); + if (accessToken) { + endpoint = "api/private/v1.0/telemetry/events"; + headers["Authorization"] = `Bearer ${accessToken}`; + } + + const url = new URL(endpoint, this.options.baseUrl); + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(events), + }); + + if (!response.ok) { + throw await ApiClientError.fromResponse(response); + } + } + // DO NOT EDIT. This is auto-generated code. async listClustersForAllProjects(options?: FetchOptions) { const { data } = await this.client.GET("/api/atlas/v2/clusters", options); diff --git a/src/config.ts b/src/config.ts index e55ca239..cea589ba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,7 @@ export interface UserConfig { apiBaseUrl?: string; apiClientId?: string; apiClientSecret?: string; + telemetry?: "enabled" | "disabled"; logPath: string; connectionString?: string; connectOptions: { diff --git a/src/index.ts b/src/index.ts index 60e2ba97..268c4803 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,5 @@ try { await server.connect(transport); } catch (error: unknown) { logger.emergency(mongoLogId(1_000_004), "server", `Fatal error running server: ${error as string}`); - process.exit(1); } diff --git a/src/server.ts b/src/server.ts index 85a8bc4f..fd16c75d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,8 @@ import { AtlasTools } from "./tools/atlas/tools.js"; import { MongoDbTools } from "./tools/mongodb/tools.js"; import logger, { initializeLogger } from "./logger.js"; import { mongoLogId } from "mongodb-log-writer"; +import { ObjectId } from "mongodb"; +import { Telemetry } from "./telemetry/telemetry.js"; import { UserConfig } from "./config.js"; export interface ServerOptions { @@ -16,17 +18,18 @@ export interface ServerOptions { export class Server { public readonly session: Session; private readonly mcpServer: McpServer; + private readonly telemetry: Telemetry; private readonly userConfig: UserConfig; constructor({ session, mcpServer, userConfig }: ServerOptions) { this.session = session; + this.telemetry = new Telemetry(session); this.mcpServer = mcpServer; this.userConfig = userConfig; } async connect(transport: Transport) { this.mcpServer.server.registerCapabilities({ logging: {} }); - this.registerTools(); this.registerResources(); @@ -34,7 +37,16 @@ export class Server { await this.mcpServer.connect(transport); - logger.info(mongoLogId(1_000_004), "server", `Server started with transport ${transport.constructor.name}`); + this.mcpServer.server.oninitialized = () => { + this.session.setAgentRunner(this.mcpServer.server.getClientVersion()); + this.session.sessionId = new ObjectId().toString(); + + logger.info( + mongoLogId(1_000_004), + "server", + `Server started with transport ${transport.constructor.name} and agent runner ${this.session.agentRunner?.name}` + ); + }; } async close(): Promise { @@ -44,7 +56,7 @@ export class Server { private registerTools() { for (const tool of [...AtlasTools, ...MongoDbTools]) { - new tool(this.session, this.userConfig).register(this.mcpServer); + new tool(this.session, this.userConfig, this.telemetry).register(this.mcpServer); } } diff --git a/src/session.ts b/src/session.ts index 8ef1932d..2c5267ce 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,5 +1,6 @@ import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; import { ApiClient, ApiClientCredentials } from "./common/atlas/apiClient.js"; +import { Implementation } from "@modelcontextprotocol/sdk/types.js"; export interface SessionOptions { apiBaseUrl?: string; @@ -8,8 +9,13 @@ export interface SessionOptions { } export class Session { + sessionId?: string; serviceProvider?: NodeDriverServiceProvider; apiClient: ApiClient; + agentRunner?: { + name: string; + version: string; + }; constructor({ apiBaseUrl, apiClientId, apiClientSecret }: SessionOptions = {}) { const credentials: ApiClientCredentials | undefined = @@ -26,6 +32,15 @@ export class Session { }); } + setAgentRunner(agentRunner: Implementation | undefined) { + if (agentRunner?.name && agentRunner?.version) { + this.agentRunner = { + name: agentRunner.name, + version: agentRunner.version, + }; + } + } + async close(): Promise { if (this.serviceProvider) { try { diff --git a/src/telemetry/constants.ts b/src/telemetry/constants.ts new file mode 100644 index 00000000..dfccbe75 --- /dev/null +++ b/src/telemetry/constants.ts @@ -0,0 +1,15 @@ +import { getMachineIdSync } from "native-machine-id"; +import { packageInfo } from "../packageInfo.js"; + +/** + * Machine-specific metadata formatted for telemetry + */ +export const MACHINE_METADATA = { + device_id: getMachineIdSync(), + mcp_server_version: packageInfo.version, + mcp_server_name: packageInfo.mcpServerName, + platform: process.platform, + arch: process.arch, + os_type: process.platform, + os_version: process.version, +} as const; diff --git a/src/telemetry/eventCache.ts b/src/telemetry/eventCache.ts new file mode 100644 index 00000000..49025227 --- /dev/null +++ b/src/telemetry/eventCache.ts @@ -0,0 +1,62 @@ +import { BaseEvent } from "./types.js"; +import { LRUCache } from "lru-cache"; + +/** + * Singleton class for in-memory telemetry event caching + * Provides a central storage for telemetry events that couldn't be sent + * Uses LRU cache to automatically drop oldest events when limit is exceeded + */ +export class EventCache { + private static instance: EventCache; + private static readonly MAX_EVENTS = 1000; + + private cache: LRUCache; + private nextId = 0; + + private constructor() { + this.cache = new LRUCache({ + max: EventCache.MAX_EVENTS, + // Using FIFO eviction strategy for events + allowStale: false, + updateAgeOnGet: false, + }); + } + + /** + * Gets the singleton instance of EventCache + * @returns The EventCache instance + */ + public static getInstance(): EventCache { + if (!EventCache.instance) { + EventCache.instance = new EventCache(); + } + return EventCache.instance; + } + + /** + * Gets a copy of the currently cached events + * @returns Array of cached BaseEvent objects + */ + public getEvents(): BaseEvent[] { + return Array.from(this.cache.values()); + } + + /** + * Appends new events to the cached events + * LRU cache automatically handles dropping oldest events when limit is exceeded + * @param events - The events to append + */ + public appendEvents(events: BaseEvent[]): void { + for (const event of events) { + this.cache.set(this.nextId++, event); + } + } + + /** + * Clears all cached events + */ + public clearEvents(): void { + this.cache.clear(); + this.nextId = 0; + } +} diff --git a/src/telemetry/telemetry.ts b/src/telemetry/telemetry.ts new file mode 100644 index 00000000..a43b11c9 --- /dev/null +++ b/src/telemetry/telemetry.ts @@ -0,0 +1,138 @@ +import { Session } from "../session.js"; +import { BaseEvent } from "./types.js"; +import { config } from "../config.js"; +import logger from "../logger.js"; +import { mongoLogId } from "mongodb-log-writer"; +import { ApiClient } from "../common/atlas/apiClient.js"; +import { MACHINE_METADATA } from "./constants.js"; +import { EventCache } from "./eventCache.js"; + +type EventResult = { + success: boolean; + error?: Error; +}; + +type CommonProperties = { + device_id: string; + mcp_server_version: string; + mcp_server_name: string; + mcp_client_version?: string; + mcp_client_name?: string; + platform: string; + arch: string; + os_type: string; + os_version?: string; + session_id?: string; +}; + +export class Telemetry { + private readonly commonProperties: CommonProperties; + + constructor( + private readonly session: Session, + private readonly eventCache: EventCache = EventCache.getInstance() + ) { + this.commonProperties = { + ...MACHINE_METADATA, + }; + } + + /** + * Checks if telemetry is currently enabled + * This is a method rather than a constant to capture runtime config changes + * + * Follows the Console Do Not Track standard (https://consoledonottrack.com/) + * by respecting the DO_NOT_TRACK environment variable + */ + private static isTelemetryEnabled(): boolean { + // Check if telemetry is explicitly disabled in config + if (config.telemetry === "disabled") { + return false; + } + + const doNotTrack = process.env.DO_NOT_TRACK; + if (doNotTrack) { + const value = doNotTrack.toLowerCase(); + // Telemetry should be disabled if DO_NOT_TRACK is "1", "true", or "yes" + if (value === "1" || value === "true" || value === "yes") { + return false; + } + } + + return true; + } + + /** + * Emits events through the telemetry pipeline + * @param events - The events to emit + */ + public async emitEvents(events: BaseEvent[]): Promise { + try { + if (!Telemetry.isTelemetryEnabled()) { + logger.debug(mongoLogId(1_000_000), "telemetry", "Telemetry is disabled, skipping events."); + return; + } + + await this.emit(events); + } catch { + logger.debug(mongoLogId(1_000_002), "telemetry", `Error emitting telemetry events.`); + } + } + + /** + * Gets the common properties for events + * @returns Object containing common properties for all events + */ + public getCommonProperties(): CommonProperties { + return { + ...this.commonProperties, + mcp_client_version: this.session.agentRunner?.version, + mcp_client_name: this.session.agentRunner?.name, + session_id: this.session.sessionId, + }; + } + + /** + * Attempts to emit events through authenticated and unauthenticated clients + * Falls back to caching if both attempts fail + */ + private async emit(events: BaseEvent[]): Promise { + const cachedEvents = this.eventCache.getEvents(); + const allEvents = [...cachedEvents, ...events]; + + logger.debug( + mongoLogId(1_000_003), + "telemetry", + `Attempting to send ${allEvents.length} events (${cachedEvents.length} cached)` + ); + + const result = await this.sendEvents(this.session.apiClient, allEvents); + if (result.success) { + this.eventCache.clearEvents(); + logger.debug(mongoLogId(1_000_004), "telemetry", `Sent ${allEvents.length} events successfully`); + return; + } + + logger.warning( + mongoLogId(1_000_005), + "telemetry", + `Error sending event to client: ${result.error instanceof Error ? result.error.message : String(result.error)}` + ); + this.eventCache.appendEvents(events); + } + + /** + * Attempts to send events through the provided API client + */ + private async sendEvents(client: ApiClient, events: BaseEvent[]): Promise { + try { + await client.sendEvents(events); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } +} diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts new file mode 100644 index 00000000..4f24e545 --- /dev/null +++ b/src/telemetry/types.ts @@ -0,0 +1,47 @@ +/** + * Result type constants for telemetry events + */ +export type TelemetryResult = "success" | "failure"; + +/** + * Base interface for all events + */ +export interface Event { + timestamp: string; + source: "mdbmcp"; + properties: Record; +} + +export interface BaseEvent extends Event { + properties: { + device_id: string; + mcp_server_version: string; + mcp_server_name: string; + mcp_client_version?: string; + mcp_client_name?: string; + platform: string; + arch: string; + os_type: string; + component: string; + duration_ms: number; + result: TelemetryResult; + category: string; + os_version?: string; + session_id?: string; + } & Event["properties"]; +} + +/** + * Interface for tool events + */ +export interface ToolEvent extends BaseEvent { + properties: { + command: string; + error_code?: string; + error_type?: string; + project_id?: string; + org_id?: string; + cluster_name?: string; + is_atlas?: boolean; + } & BaseEvent["properties"]; +} diff --git a/src/tools/tool.ts b/src/tools/tool.ts index a0d0f688..5cbb8ac2 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -1,9 +1,11 @@ -import { McpServer, ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z, ZodNever, ZodRawShape } from "zod"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z, type ZodRawShape, type ZodNever } from "zod"; +import type { McpServer, ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { Session } from "../session.js"; import logger from "../logger.js"; import { mongoLogId } from "mongodb-log-writer"; +import { Telemetry } from "../telemetry/telemetry.js"; +import { type ToolEvent } from "../telemetry/types.js"; import { UserConfig } from "../config.js"; export type ToolArgs = z.objectOutputType; @@ -26,28 +28,55 @@ export abstract class ToolBase { constructor( protected readonly session: Session, - protected readonly config: UserConfig + protected readonly config: UserConfig, + protected readonly telemetry: Telemetry ) {} + /** + * Creates and emits a tool telemetry event + * @param startTime - Start time in milliseconds + * @param result - Whether the command succeeded or failed + * @param error - Optional error if the command failed + */ + private async emitToolEvent(startTime: number, result: CallToolResult): Promise { + const duration = Date.now() - startTime; + const event: ToolEvent = { + timestamp: new Date().toISOString(), + source: "mdbmcp", + properties: { + ...this.telemetry.getCommonProperties(), + command: this.name, + category: this.category, + component: "tool", + duration_ms: duration, + result: result.isError ? "failure" : "success", + }, + }; + await this.telemetry.emitEvents([event]); + } + public register(server: McpServer): void { if (!this.verifyAllowed()) { return; } const callback: ToolCallback = async (...args) => { + const startTime = Date.now(); try { - // TODO: add telemetry here logger.debug( mongoLogId(1_000_006), "tool", `Executing ${this.name} with args: ${JSON.stringify(args)}` ); - return await this.execute(...args); + const result = await this.execute(...args); + await this.emitToolEvent(startTime, result); + return result; } catch (error: unknown) { logger.error(mongoLogId(1_000_000), "tool", `Error executing ${this.name}: ${error as string}`); - - return await this.handleError(error, args[0] as ToolArgs); + const toolResult = await this.handleError(error, args[0] as ToolArgs); + await this.emitToolEvent(startTime, toolResult).catch(() => {}); + return toolResult; } }; @@ -91,7 +120,6 @@ export abstract class ToolBase { text: `Error running ${this.name}: ${error instanceof Error ? error.message : String(error)}`, }, ], - isError: true, }; } } diff --git a/src/types/native-machine-id.d.ts b/src/types/native-machine-id.d.ts new file mode 100644 index 00000000..153dbf38 --- /dev/null +++ b/src/types/native-machine-id.d.ts @@ -0,0 +1,34 @@ +/** + * Type definitions for native-machine-id + * Provides functionality to retrieve the machine ID of the current device. + */ + +declare module "native-machine-id" { + /** + * Gets the machine ID synchronously. + * @returns A string containing the machine ID. + */ + export function getMachineIdSync(): string; + + /** + * Gets the machine ID asynchronously. + * @returns A Promise that resolves to a string containing the machine ID. + */ + export function getMachineId(): Promise; + + /** + * Gets a machine ID that is based on the original ID but is "hashed" for privacy. + * @param {string} [original] - The original ID to hash. If not provided, gets the machine ID first. + * @param {string} [type='md5'] - The hashing algorithm to use. + * @returns A Promise that resolves to a string containing the hashed machine ID. + */ + export function machineIdSync(original?: string, type?: string): string; + + /** + * Gets a machine ID that is based on the original ID but is "hashed" for privacy. + * @param {string} [original] - The original ID to hash. If not provided, gets the machine ID first. + * @param {string} [type='md5'] - The hashing algorithm to use. + * @returns A Promise that resolves to a string containing the hashed machine ID. + */ + export function machineId(original?: string, type?: string): Promise; +} diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 4e236b1a..79a679a0 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -77,6 +77,7 @@ export function setupIntegrationTest(userConfig: UserConfig = config): Integrati }); beforeEach(async () => { + config.telemetry = "disabled"; randomDbName = new ObjectId().toString(); });