Skip to content

chore: [MCP-2] add is_container_env to telemetry #298

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

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ try {
version: packageInfo.version,
});

const telemetry = Telemetry.create(session, config);
const telemetry = await Telemetry.create(session, config);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const telemetry = await Telemetry.create(session, config);
const telemetry = Telemetry.create(session, config);

the creation logic does not be turned async.

I'd say the main change needed is to add this.commonProperties.is_container_env intialization into start (possibly rename it to setup as that naming seems confusing)


const server = new Server({
mcpServer,
Expand Down
128 changes: 75 additions & 53 deletions src/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,56 +7,92 @@ import { MACHINE_METADATA } from "./constants.js";
import { EventCache } from "./eventCache.js";
import nodeMachineId from "node-machine-id";
import { getDeviceId } from "@mongodb-js/device-id";
import fs from "fs/promises";

async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath, fs.constants.F_OK);
return true; // File exists
} catch (e: unknown) {
if (
e instanceof Error &&
(
e as Error & {
code: string;
}
).code === "ENOENT"
) {
return false; // File does not exist
}
throw e; // Re-throw unexpected errors
}
}

type EventResult = {
success: boolean;
error?: Error;
};
async function isContainerized(): Promise<boolean> {
if (process.env.container) {
return true;
}

const exists = await Promise.all(["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"].map(fileExists));

export const DEVICE_ID_TIMEOUT = 3000;
return exists.includes(true);
}

export class Telemetry {
private isBufferingEvents: boolean = true;
/** Resolves when the device ID is retrieved or timeout occurs */
public deviceIdPromise: Promise<string> | undefined;
private deviceId: string | undefined;
private containerEnv: boolean | undefined;
Comment on lines +42 to +43
Copy link
Collaborator

@gagik gagik Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not have them inside commonProperties so it easier to see which fields in the state are used solely for common properties?

private deviceIdAbortController = new AbortController();
private eventCache: EventCache;
private getRawMachineId: () => Promise<string>;
private getContainerEnv: () => Promise<boolean>;

private constructor(
private readonly session: Session,
private readonly userConfig: UserConfig,
private readonly commonProperties: CommonProperties,
{ eventCache, getRawMachineId }: { eventCache: EventCache; getRawMachineId: () => Promise<string> }
{
eventCache,
getRawMachineId,
getContainerEnv,
}: {
eventCache: EventCache;
getRawMachineId: () => Promise<string>;
getContainerEnv: () => Promise<boolean>;
}
) {
this.eventCache = eventCache;
this.getRawMachineId = getRawMachineId;
this.getContainerEnv = getContainerEnv;
}

static create(
static async create(
session: Session,
userConfig: UserConfig,
{
commonProperties = { ...MACHINE_METADATA },
eventCache = EventCache.getInstance(),
getRawMachineId = () => nodeMachineId.machineId(true),
getContainerEnv = isContainerized,
}: {
eventCache?: EventCache;
getRawMachineId?: () => Promise<string>;
commonProperties?: CommonProperties;
getContainerEnv?: () => Promise<boolean>;
} = {}
): Telemetry {
const instance = new Telemetry(session, userConfig, commonProperties, { eventCache, getRawMachineId });
): Promise<Telemetry> {
const instance = new Telemetry(session, userConfig, {
eventCache,
getRawMachineId,
getContainerEnv,
});

void instance.start();
await instance.start();
Copy link
Collaborator

@gagik gagik Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await instance.start();
void instance.start();

This was an important detail in the existing implementation: start was not getting awaited before. This is why the behavior is equivalent to what was trying to be accomplished before with skipping the promise. Telemetry was already not waiting for the device ID resolution.

So the create function does not need to be asynchronous and can be left as is.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3s is nothing to wait, I think the code looks much better to wait on promises instead of "ignoring" them, I can move to last

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding potentially 3 seconds to the MCP server startup time for no real functional benefit is absolutely non-trivial. We shouldn't be slowing down anything in our logic for the sake of telemetry. This is also not ignoring a promise, this is explicitly spawning a parallel async operation here and the void keyword exists for that reason.

If you want, you can separate the refactor bits of this PR into its own thing and we can have a larger discussion about those changes but in places like mongosh we absolutely do care about startup time so when consolidating telemetry we'd likely end up with the original structure eventually.

return instance;
}

private async start(): Promise<void> {
if (!this.isTelemetryEnabled()) {
return;
}
this.deviceIdPromise = getDeviceId({

const deviceIdPromise = getDeviceId({
getMachineId: () => this.getRawMachineId(),
onError: (reason, error) => {
switch (reason) {
Expand All @@ -73,15 +109,13 @@ export class Telemetry {
},
abortSignal: this.deviceIdAbortController.signal,
});
const containerEnvPromise = this.getContainerEnv();

this.commonProperties.device_id = await this.deviceIdPromise;

this.isBufferingEvents = false;
[this.deviceId, this.containerEnv] = await Promise.all([deviceIdPromise, containerEnvPromise]);
}

public async close(): Promise<void> {
this.deviceIdAbortController.abort();
this.isBufferingEvents = false;
await this.emitEvents(this.eventCache.getEvents());
}

Expand All @@ -106,14 +140,16 @@ export class Telemetry {
* Gets the common properties for events
* @returns Object containing common properties for all events
*/
public getCommonProperties(): CommonProperties {
private getCommonProperties(): CommonProperties {
return {
...this.commonProperties,
...MACHINE_METADATA,
mcp_client_version: this.session.agentRunner?.version,
mcp_client_name: this.session.agentRunner?.name,
session_id: this.session.sessionId,
config_atlas_auth: this.session.apiClient.hasCredentials() ? "true" : "false",
config_connection_string: this.userConfig.connectionString ? "true" : "false",
is_container_env: this.containerEnv ? "true" : "false",
device_id: this.deviceId,
};
}

Expand All @@ -139,11 +175,6 @@ export class Telemetry {
* Falls back to caching if both attempts fail
*/
private async emit(events: BaseEvent[]): Promise<void> {
if (this.isBufferingEvents) {
this.eventCache.appendEvents(events);
return;
}

const cachedEvents = this.eventCache.getEvents();
const allEvents = [...cachedEvents, ...events];

Expand All @@ -153,42 +184,33 @@ export class Telemetry {
`Attempting to send ${allEvents.length} events (${cachedEvents.length} cached)`
);

const result = await this.sendEvents(this.session.apiClient, allEvents);
if (result.success) {
try {
await this.sendEvents(this.session.apiClient, allEvents);
this.eventCache.clearEvents();
logger.debug(
LogId.telemetryEmitSuccess,
"telemetry",
`Sent ${allEvents.length} events successfully: ${JSON.stringify(allEvents, null, 2)}`
);
return;
} catch (error: unknown) {
logger.debug(
LogId.telemetryEmitFailure,
"telemetry",
`Error sending event to client: ${error instanceof Error ? error.message : String(error)}`
);
this.eventCache.appendEvents(events);
}

logger.debug(
LogId.telemetryEmitFailure,
"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<EventResult> {
try {
await client.sendEvents(
events.map((event) => ({
...event,
properties: { ...this.getCommonProperties(), ...event.properties },
}))
);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
private async sendEvents(client: ApiClient, events: BaseEvent[]): Promise<void> {
await client.sendEvents(
events.map((event) => ({
...event,
properties: { ...this.getCommonProperties(), ...event.properties },
}))
);
}
}
1 change: 1 addition & 0 deletions src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,5 @@ export type CommonProperties = {
config_atlas_auth?: TelemetryBoolSet;
config_connection_string?: TelemetryBoolSet;
session_id?: string;
is_container_env?: TelemetryBoolSet;
} & CommonStaticProperties;
2 changes: 1 addition & 1 deletion tests/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function setupIntegrationTest(getUserConfig: () => UserConfig): Integrati

userConfig.telemetry = "disabled";

const telemetry = Telemetry.create(session, userConfig);
const telemetry = await Telemetry.create(session, userConfig);

mcpServer = new Server({
session,
Expand Down
28 changes: 0 additions & 28 deletions tests/integration/telemetry.test.ts

This file was deleted.

Loading
Loading