diff --git a/src/telemetry/telemetry.ts b/src/telemetry/telemetry.ts index ccf0eb41..e9aa3ba2 100644 --- a/src/telemetry/telemetry.ts +++ b/src/telemetry/telemetry.ts @@ -7,30 +7,69 @@ 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"; type EventResult = { success: boolean; error?: Error; }; -export const DEVICE_ID_TIMEOUT = 3000; +async function fileExists(filePath: string): Promise { + try { + await fs.stat(filePath); + 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 + } +} + +async function isContainerized(): Promise { + for (const file of ["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"]) { + const exists = await fileExists(file); + if (exists) { + return true; + } + } + return !!process.env.container; +} export class Telemetry { - private isBufferingEvents: boolean = true; /** Resolves when the device ID is retrieved or timeout occurs */ + private bufferingEvents: number = 2; public deviceIdPromise: Promise | undefined; + public containerEnvPromise: Promise | undefined; private deviceIdAbortController = new AbortController(); private eventCache: EventCache; private getRawMachineId: () => Promise; + private getContainerEnv: () => Promise; private constructor( private readonly session: Session, private readonly userConfig: UserConfig, private readonly commonProperties: CommonProperties, - { eventCache, getRawMachineId }: { eventCache: EventCache; getRawMachineId: () => Promise } + { + eventCache, + getRawMachineId, + getContainerEnv, + }: { + eventCache: EventCache; + getRawMachineId: () => Promise; + getContainerEnv: () => Promise; + } ) { this.eventCache = eventCache; this.getRawMachineId = getRawMachineId; + this.getContainerEnv = getContainerEnv; } static create( @@ -40,22 +79,29 @@ export class Telemetry { commonProperties = { ...MACHINE_METADATA }, eventCache = EventCache.getInstance(), getRawMachineId = () => nodeMachineId.machineId(true), + getContainerEnv = isContainerized, }: { + commonProperties?: CommonProperties; eventCache?: EventCache; getRawMachineId?: () => Promise; - commonProperties?: CommonProperties; + getContainerEnv?: () => Promise; } = {} ): Telemetry { - const instance = new Telemetry(session, userConfig, commonProperties, { eventCache, getRawMachineId }); + const instance = new Telemetry(session, userConfig, commonProperties, { + eventCache, + getRawMachineId, + getContainerEnv, + }); - void instance.start(); + instance.start(); return instance; } - private async start(): Promise { + private start(): void { if (!this.isTelemetryEnabled()) { return; } + this.deviceIdPromise = getDeviceId({ getMachineId: () => this.getRawMachineId(), onError: (reason, error) => { @@ -72,16 +118,16 @@ export class Telemetry { } }, abortSignal: this.deviceIdAbortController.signal, + }).finally(() => { + this.bufferingEvents--; + }); + this.containerEnvPromise = this.getContainerEnv().finally(() => { + this.bufferingEvents--; }); - - this.commonProperties.device_id = await this.deviceIdPromise; - - this.isBufferingEvents = false; } public async close(): Promise { this.deviceIdAbortController.abort(); - this.isBufferingEvents = false; await this.emitEvents(this.eventCache.getEvents()); } @@ -117,6 +163,14 @@ export class Telemetry { }; } + public async getAsyncCommonProperties(): Promise { + return { + ...this.getCommonProperties(), + is_container_env: (await this.containerEnvPromise) ? "true" : "false", + device_id: await this.deviceIdPromise, + }; + } + /** * Checks if telemetry is currently enabled * This is a method rather than a constant to capture runtime config changes @@ -134,12 +188,16 @@ export class Telemetry { return !doNotTrack; } + public isBufferingEvents(): boolean { + return this.bufferingEvents > 0; + } + /** * Attempts to emit events through authenticated and unauthenticated clients * Falls back to caching if both attempts fail */ private async emit(events: BaseEvent[]): Promise { - if (this.isBufferingEvents) { + if (this.isBufferingEvents()) { this.eventCache.appendEvents(events); return; } @@ -177,10 +235,11 @@ export class Telemetry { */ private async sendEvents(client: ApiClient, events: BaseEvent[]): Promise { try { + const commonProperties = await this.getAsyncCommonProperties(); await client.sendEvents( events.map((event) => ({ ...event, - properties: { ...this.getCommonProperties(), ...event.properties }, + properties: { ...commonProperties, ...event.properties }, })) ); return { success: true }; diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index d77cc010..05ce8f3f 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -71,4 +71,5 @@ export type CommonProperties = { config_atlas_auth?: TelemetryBoolSet; config_connection_string?: TelemetryBoolSet; session_id?: string; + is_container_env?: TelemetryBoolSet; } & CommonStaticProperties; diff --git a/tests/integration/telemetry.test.ts b/tests/integration/telemetry.test.ts index 522c1154..9b283c91 100644 --- a/tests/integration/telemetry.test.ts +++ b/tests/integration/telemetry.test.ts @@ -7,22 +7,16 @@ import nodeMachineId from "node-machine-id"; describe("Telemetry", () => { it("should resolve the actual machine ID", async () => { const actualId: string = await nodeMachineId.machineId(true); - const actualHashedId = createHmac("sha256", actualId.toUpperCase()).update("atlascli").digest("hex"); - const telemetry = Telemetry.create( - new Session({ - apiBaseUrl: "", - }), - config - ); - - expect(telemetry.getCommonProperties().device_id).toBe(undefined); - expect(telemetry["isBufferingEvents"]).toBe(true); + const telemetry = Telemetry.create(new Session({ apiBaseUrl: "" }), config, { + getContainerEnv: () => new Promise((resolve) => resolve(false)), + }); - await telemetry.deviceIdPromise; + expect(telemetry.isBufferingEvents()).toBe(true); + const commonProps = await telemetry.getAsyncCommonProperties(); - expect(telemetry.getCommonProperties().device_id).toBe(actualHashedId); - expect(telemetry["isBufferingEvents"]).toBe(false); + expect(commonProps.device_id).toBe(actualHashedId); + expect(telemetry.isBufferingEvents()).toBe(false); }); }); diff --git a/tests/unit/telemetry.test.ts b/tests/unit/telemetry.test.ts index c1ae28ea..252973d1 100644 --- a/tests/unit/telemetry.test.ts +++ b/tests/unit/telemetry.test.ts @@ -1,6 +1,6 @@ import { ApiClient } from "../../src/common/atlas/apiClient.js"; import { Session } from "../../src/session.js"; -import { DEVICE_ID_TIMEOUT, Telemetry } from "../../src/telemetry/telemetry.js"; +import { Telemetry } from "../../src/telemetry/telemetry.js"; import { BaseEvent, TelemetryResult } from "../../src/telemetry/types.js"; import { EventCache } from "../../src/telemetry/eventCache.js"; import { config } from "../../src/config.js"; @@ -55,7 +55,7 @@ describe("Telemetry", () => { } // Helper function to verify mock calls to reduce duplication - function verifyMockCalls({ + async function verifyMockCalls({ sendEventsCalls = 0, clearEventsCalls = 0, appendEventsCalls = 0, @@ -77,11 +77,13 @@ describe("Telemetry", () => { expect(appendEvents.length).toBe(appendEventsCalls); if (sendEventsCalledWith) { + const commonProps = await telemetry.getAsyncCommonProperties(); + expect(sendEvents[0]?.[0]).toEqual( sendEventsCalledWith.map((event) => ({ ...event, properties: { - ...telemetry.getCommonProperties(), + ...commonProps, ...event.properties, }, })) @@ -128,6 +130,7 @@ describe("Telemetry", () => { telemetry = Telemetry.create(session, config, { eventCache: mockEventCache, getRawMachineId: () => Promise.resolve(machineId), + getContainerEnv: () => Promise.resolve(false), }); config.telemetry = "enabled"; @@ -140,7 +143,7 @@ describe("Telemetry", () => { await telemetry.emitEvents([testEvent]); - verifyMockCalls({ + await verifyMockCalls({ sendEventsCalls: 1, clearEventsCalls: 1, sendEventsCalledWith: [testEvent], @@ -154,7 +157,7 @@ describe("Telemetry", () => { await telemetry.emitEvents([testEvent]); - verifyMockCalls({ + await verifyMockCalls({ sendEventsCalls: 1, appendEventsCalls: 1, appendEventsCalledWith: [testEvent], @@ -177,16 +180,14 @@ describe("Telemetry", () => { await telemetry.emitEvents([newEvent]); - verifyMockCalls({ + await verifyMockCalls({ sendEventsCalls: 1, clearEventsCalls: 1, sendEventsCalledWith: [cachedEvent, newEvent], }); }); - it("should correctly add common properties to events", () => { - const commonProps = telemetry.getCommonProperties(); - + it("should correctly add common properties to events", async () => { // Use explicit type assertion const expectedProps: Record = { mcp_client_version: "1.0.0", @@ -197,6 +198,8 @@ describe("Telemetry", () => { device_id: hashedMachineId, }; + const commonProps = await telemetry.getAsyncCommonProperties(); + expect(commonProps).toMatchObject(expectedProps); }); @@ -214,15 +217,13 @@ describe("Telemetry", () => { it("should successfully resolve the machine ID", async () => { telemetry = Telemetry.create(session, config, { getRawMachineId: () => Promise.resolve(machineId), + getContainerEnv: () => Promise.resolve(false), }); - expect(telemetry["isBufferingEvents"]).toBe(true); - expect(telemetry.getCommonProperties().device_id).toBe(undefined); - - await telemetry.deviceIdPromise; - - expect(telemetry["isBufferingEvents"]).toBe(false); - expect(telemetry.getCommonProperties().device_id).toBe(hashedMachineId); + expect(telemetry.isBufferingEvents()).toBe(true); + const commonProps = await telemetry.getAsyncCommonProperties(); + expect(telemetry.isBufferingEvents()).toBe(false); + expect(commonProps.device_id).toBe(hashedMachineId); }); it("should handle machine ID resolution failure", async () => { @@ -230,15 +231,13 @@ describe("Telemetry", () => { telemetry = Telemetry.create(session, config, { getRawMachineId: () => Promise.reject(new Error("Failed to get device ID")), + getContainerEnv: () => Promise.resolve(false), }); - expect(telemetry["isBufferingEvents"]).toBe(true); - expect(telemetry.getCommonProperties().device_id).toBe(undefined); - - await telemetry.deviceIdPromise; - - expect(telemetry["isBufferingEvents"]).toBe(false); - expect(telemetry.getCommonProperties().device_id).toBe("unknown"); + expect(telemetry.isBufferingEvents()).toBe(true); + const commonProps = await telemetry.getAsyncCommonProperties(); + expect(telemetry.isBufferingEvents()).toBe(false); + expect(commonProps.device_id).toBe("unknown"); expect(loggerSpy).toHaveBeenCalledWith( LogId.telemetryDeviceIdFailure, @@ -248,25 +247,17 @@ describe("Telemetry", () => { }); it("should timeout if machine ID resolution takes too long", async () => { + const DEVICE_ID_TIMEOUT = 3000; const loggerSpy = jest.spyOn(logger, "debug"); - telemetry = Telemetry.create(session, config, { getRawMachineId: () => new Promise(() => {}) }); - - expect(telemetry["isBufferingEvents"]).toBe(true); - expect(telemetry.getCommonProperties().device_id).toBe(undefined); - - jest.advanceTimersByTime(DEVICE_ID_TIMEOUT / 2); - - // Make sure the timeout doesn't happen prematurely. - expect(telemetry["isBufferingEvents"]).toBe(true); - expect(telemetry.getCommonProperties().device_id).toBe(undefined); - + telemetry = Telemetry.create(session, config, { + getRawMachineId: () => new Promise(() => {}), + getContainerEnv: () => Promise.resolve(false), + }); + expect(telemetry.isBufferingEvents()).toBe(true); jest.advanceTimersByTime(DEVICE_ID_TIMEOUT); - - await telemetry.deviceIdPromise; - - expect(telemetry.getCommonProperties().device_id).toBe("unknown"); - expect(telemetry["isBufferingEvents"]).toBe(false); + const commonProps = await telemetry.getAsyncCommonProperties(); + expect(commonProps.device_id).toBe("unknown"); expect(loggerSpy).toHaveBeenCalledWith( LogId.telemetryDeviceIdTimeout, "telemetry", @@ -290,7 +281,7 @@ describe("Telemetry", () => { await telemetry.emitEvents([testEvent]); - verifyMockCalls(); + await verifyMockCalls(); }); }); @@ -315,7 +306,7 @@ describe("Telemetry", () => { await telemetry.emitEvents([testEvent]); - verifyMockCalls(); + await verifyMockCalls(); }); }); });