-
Notifications
You must be signed in to change notification settings - Fork 85
feat: core telemetry functionality #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
9cf0447
1b8ea02
af714b4
40aaef0
8adc20d
cff01e0
5336e4a
76e2a6f
1850676
3a5d4c1
23fff05
754ce8d
8301e43
994b698
3bfc9f2
6d92021
7997c54
f18b916
a9cd835
6813518
65fad1d
7e98c33
5209073
2e33584
d92adf1
8ec7d9c
6ad2a19
807109d
f9a46f9
599201d
7a889b4
eb360e2
710b131
a15eeb6
90caa25
89113a5
143f898
f751a30
0927d28
fb0b8af
9e625aa
a61d9b4
2bd00cf
3636fde
0df870c
4b7563d
6dd1f79
807b2ca
139c3ee
3a05d31
8505d91
024a3d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,6 +11,7 @@ interface UserConfig { | |
apiBaseUrl?: string; | ||
apiClientId?: string; | ||
apiClientSecret?: string; | ||
telemetry?: "enabled" | "disabled"; | ||
blva marked this conversation as resolved.
Show resolved
Hide resolved
|
||
logPath: string; | ||
connectionString?: string; | ||
connectOptions: { | ||
|
@@ -39,9 +40,20 @@ const mergedUserConfig = { | |
...getCliConfig(), | ||
}; | ||
|
||
const machineMetadata = { | ||
device_id: "id", // TODO: use @mongodb-js/machine-id | ||
platform: process.platform, | ||
arch: process.arch, | ||
os_type: process.platform, | ||
os_version: process.version, | ||
}; | ||
|
||
const config = { | ||
...mergedUserConfig, | ||
...machineMetadata, | ||
blva marked this conversation as resolved.
Show resolved
Hide resolved
|
||
version: packageJson.version, | ||
mcpServerName: "MdbMcpServer", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. will this ever change? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not AFAIK There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be nice if constants lived outside config, maybe a something like
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok, I added but I thought we could keep it in config for now |
||
isTelemetryEnabled: true, | ||
blva marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}; | ||
|
||
export default config; | ||
gagik marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
import { Session } from "../session.js"; | ||
import { BaseEvent, type ToolEvent } from "./types.js"; | ||
import pkg from "../../package.json" with { type: "json" }; | ||
import config from "../config.js"; | ||
import logger from "../logger.js"; | ||
import { mongoLogId } from "mongodb-log-writer"; | ||
import { ApiClient } from "../common/atlas/apiClient.js"; | ||
import fs from "fs/promises"; | ||
import path from "path"; | ||
|
||
const TELEMETRY_ENABLED = config.telemetry !== "disabled"; | ||
const CACHE_FILE = path.join(process.cwd(), ".telemetry-cache.json"); | ||
|
||
interface TelemetryError extends Error { | ||
code?: string; | ||
} | ||
|
||
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) { | ||
// Ensure all required properties are present | ||
blva marked this conversation as resolved.
Show resolved
Hide resolved
|
||
this.commonProperties = Object.freeze({ | ||
blva marked this conversation as resolved.
Show resolved
Hide resolved
|
||
device_id: config.device_id, | ||
mcp_server_version: pkg.version, | ||
mcp_server_name: config.mcpServerName, | ||
mcp_client_version: this.session.agentClientVersion, | ||
mcp_client_name: this.session.agentClientName, | ||
platform: config.platform, | ||
arch: config.arch, | ||
os_type: config.os_type, | ||
os_version: config.os_version, | ||
}); | ||
} | ||
|
||
/** | ||
* Emits a tool event with timing and error information | ||
* @param command - The command being executed | ||
* @param category - Category of the command | ||
* @param startTime - Start time in milliseconds | ||
* @param result - Whether the command succeeded or failed | ||
* @param error - Optional error if the command failed | ||
*/ | ||
public async emitToolEvent( | ||
blva marked this conversation as resolved.
Show resolved
Hide resolved
|
||
command: string, | ||
category: string, | ||
startTime: number, | ||
result: "success" | "failure", | ||
error?: Error | ||
): Promise<void> { | ||
if (!TELEMETRY_ENABLED) { | ||
logger.debug(mongoLogId(1_000_000), "telemetry", "Telemetry is disabled, skipping event."); | ||
return; | ||
} | ||
|
||
const event = this.createToolEvent(command, category, startTime, result, error); | ||
await this.emit([event]); | ||
} | ||
|
||
/** | ||
* Creates a tool event with common properties and timing information | ||
*/ | ||
private createToolEvent( | ||
blva marked this conversation as resolved.
Show resolved
Hide resolved
|
||
command: string, | ||
category: string, | ||
startTime: number, | ||
result: "success" | "failure", | ||
error?: Error | ||
): ToolEvent { | ||
const duration = Date.now() - startTime; | ||
|
||
const event: ToolEvent = { | ||
timestamp: new Date().toISOString(), | ||
source: "mdbmcp", | ||
properties: { | ||
...this.commonProperties, | ||
command, | ||
category, | ||
duration_ms: duration, | ||
session_id: this.session.sessionId, | ||
result, | ||
...(error && { | ||
error_type: error.name, | ||
error_code: error.message, | ||
}), | ||
}, | ||
}; | ||
|
||
return event; | ||
} | ||
|
||
/** | ||
* Attempts to emit events through authenticated and unauthenticated clients | ||
* Falls back to caching if both attempts fail | ||
*/ | ||
private async emit(events: BaseEvent[]): Promise<void> { | ||
const cachedEvents = await this.readCache(); | ||
const allEvents = [...cachedEvents, ...events]; | ||
|
||
logger.debug( | ||
mongoLogId(1_000_000), | ||
"telemetry", | ||
`Attempting to send ${allEvents.length} events (${cachedEvents.length} cached)` | ||
); | ||
|
||
const result = await this.sendEvents(this.session.apiClient, allEvents); | ||
if (result.success) { | ||
await this.clearCache(); | ||
return; | ||
} | ||
|
||
logger.warning(mongoLogId(1_000_000), "telemetry", `Error sending event to client: ${result.error}`); | ||
await this.cacheEvents(allEvents); | ||
} | ||
|
||
/** | ||
* Attempts to send events through the provided API client | ||
*/ | ||
private async sendEvents(client: ApiClient, events: BaseEvent[]): Promise<EventResult> { | ||
try { | ||
await client.sendEvents(events); | ||
return { success: true }; | ||
} catch (error) { | ||
return { | ||
success: false, | ||
error: error instanceof Error ? error : new Error(String(error)), | ||
}; | ||
} | ||
} | ||
|
||
/** | ||
* Reads cached events from disk | ||
* Returns empty array if no cache exists or on read error | ||
*/ | ||
private async readCache(): Promise<BaseEvent[]> { | ||
blva marked this conversation as resolved.
Show resolved
Hide resolved
|
||
try { | ||
const data = await fs.readFile(CACHE_FILE, "utf-8"); | ||
return JSON.parse(data) as BaseEvent[]; | ||
} catch (error) { | ||
const typedError = error as TelemetryError; | ||
if (typedError.code !== "ENOENT") { | ||
logger.warning( | ||
mongoLogId(1_000_000), | ||
"telemetry", | ||
`Error reading telemetry cache: ${typedError.message}` | ||
); | ||
} | ||
return []; | ||
} | ||
} | ||
|
||
/** | ||
* Caches events to disk for later sending | ||
*/ | ||
private async cacheEvents(events: BaseEvent[]): Promise<void> { | ||
try { | ||
await fs.writeFile(CACHE_FILE, JSON.stringify(events, null, 2)); | ||
logger.debug(mongoLogId(1_000_000), "telemetry", `Cached ${events.length} events for later sending`); | ||
} catch (error) { | ||
logger.warning( | ||
mongoLogId(1_000_000), | ||
"telemetry", | ||
`Failed to cache telemetry events: ${error instanceof Error ? error.message : String(error)}` | ||
); | ||
} | ||
} | ||
|
||
/** | ||
* Clears the event cache after successful sending | ||
*/ | ||
private async clearCache(): Promise<void> { | ||
try { | ||
await fs.unlink(CACHE_FILE); | ||
} catch (error) { | ||
const typedError = error as TelemetryError; | ||
if (typedError.code !== "ENOENT") { | ||
logger.warning( | ||
mongoLogId(1_000_000), | ||
"telemetry", | ||
`Error clearing telemetry cache: ${typedError.message}` | ||
); | ||
} | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.