Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export class Server {

if (command === "start") {
event.properties.startup_time_ms = commandDuration;
event.properties.read_only_mode = this.userConfig.readOnly || false;
event.properties.read_only_mode = this.userConfig.readOnly ? "true" : "false";
event.properties.disabled_tools = this.userConfig.disabledTools || [];
event.properties.confirmation_required_tools = this.userConfig.confirmationRequiredTools || [];
}
Expand Down
10 changes: 7 additions & 3 deletions src/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { EventCache } from "./eventCache.js";
import { detectContainerEnv } from "../helpers/container.js";
import type { DeviceId } from "../helpers/deviceId.js";
import { EventEmitter } from "events";
import { redact } from "mongodb-redact";

type EventResult = {
success: boolean;
Expand Down Expand Up @@ -79,7 +80,7 @@ export class Telemetry {
const [deviceIdValue, containerEnv] = await this.setupPromise;

this.commonProperties.device_id = deviceIdValue;
this.commonProperties.is_container_env = containerEnv;
this.commonProperties.is_container_env = containerEnv ? "true" : "false";

this.isBufferingEvents = false;
}
Expand Down Expand Up @@ -123,7 +124,6 @@ export class Telemetry {
this.events.emit("events-skipped");
return;
}

// Don't wait for events to be sent - we should not block regular server
// operations on telemetry
void this.emit(events);
Expand Down Expand Up @@ -214,13 +214,17 @@ export class Telemetry {

/**
* Attempts to send events through the provided API client
* Events are redacted before being sent to ensure no sensitive data is transmitted
*/
private async sendEvents(client: ApiClient, events: BaseEvent[]): Promise<EventResult> {
try {
await client.sendEvents(
events.map((event) => ({
...event,
properties: { ...this.getCommonProperties(), ...event.properties },
properties: {
...redact(this.getCommonProperties(), this.session.keychain.allSecrets),
...redact(event.properties, this.session.keychain.allSecrets),
},
}))
);
return { success: true };
Expand Down
4 changes: 2 additions & 2 deletions src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export type ServerEventProperties = {
reason?: string;
startup_time_ms?: number;
runtime_duration_ms?: number;
read_only_mode?: boolean;
read_only_mode?: TelemetryBoolSet;
disabled_tools?: string[];
confirmation_required_tools?: string[];
};
Expand Down Expand Up @@ -97,7 +97,7 @@ export type CommonProperties = {
/**
* A boolean indicating whether the server is running in a container environment.
*/
is_container_env?: boolean;
is_container_env?: TelemetryBoolSet;

/**
* The version of the MCP client as reported by the client on session establishment.
Expand Down
89 changes: 89 additions & 0 deletions tests/unit/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { NullLogger } from "../../tests/utils/index.js";
import type { MockedFunction } from "vitest";
import type { DeviceId } from "../../src/helpers/deviceId.js";
import { expectDefined } from "../integration/helpers.js";
import { Keychain } from "../../src/common/keychain.js";

// Mock the ApiClient to avoid real API calls
vi.mock("../../src/common/atlas/apiClient.js");
Expand Down Expand Up @@ -140,6 +141,7 @@ describe("Telemetry", () => {
close: vi.fn().mockResolvedValue(undefined),
setAgentRunner: vi.fn().mockResolvedValue(undefined),
logger: new NullLogger(),
keychain: new Keychain(),
} as unknown as Session;

telemetry = Telemetry.create(session, config, mockDeviceId, {
Expand Down Expand Up @@ -345,4 +347,91 @@ describe("Telemetry", () => {
verifyMockCalls();
});
});

describe("when secrets are registered", () => {
describe("comprehensive redaction coverage", () => {
it("should redact sensitive data from CommonStaticProperties", async () => {
session.keychain.register("secret-server-version", "password");
session.keychain.register("secret-server-name", "password");
session.keychain.register("secret-password", "password");
session.keychain.register("secret-key", "password");
session.keychain.register("secret-token", "password");
session.keychain.register("secret-password-version", "password");

// Simulates sensitive data across random properties
const sensitiveStaticProps = {
mcp_server_version: "secret-server-version",
mcp_server_name: "secret-server-name",
platform: "linux-secret-password",
arch: "x64-secret-key",
os_type: "linux-secret-token",
os_version: "secret-password-version",
};

telemetry = Telemetry.create(session, config, mockDeviceId, {
eventCache: mockEventCache as unknown as EventCache,
commonProperties: sensitiveStaticProps,
});

await telemetry.setupPromise;

telemetry.emitEvents([createTestEvent()]);

const calls = mockApiClient.sendEvents.mock.calls;
expect(calls).toHaveLength(1);

// get event properties
const sentEvent = calls[0]?.[0][0] as { properties: Record<string, unknown> };
expectDefined(sentEvent);

const eventProps = sentEvent.properties;
expect(eventProps.mcp_server_version).toBe("<password>");
expect(eventProps.mcp_server_name).toBe("<password>");
expect(eventProps.platform).toBe("linux-<password>");
expect(eventProps.arch).toBe("x64-<password>");
expect(eventProps.os_type).toBe("linux-<password>");
expect(eventProps.os_version).toBe("<password>-version");
});

it("should redact sensitive data from CommonProperties", () => {
// register the common properties as sensitive data
session.keychain.register("test-device-id", "password");
session.keychain.register(session.sessionId, "password");

telemetry.emitEvents([createTestEvent()]);

const calls = mockApiClient.sendEvents.mock.calls;
expect(calls).toHaveLength(1);

// get event properties
const sentEvent = calls[0]?.[0][0] as { properties: Record<string, unknown> };
expectDefined(sentEvent);

const eventProps = sentEvent.properties;

expect(eventProps.device_id).toBe("<password>");
expect(eventProps.session_id).toBe("<password>");
});

it("should redact sensitive data that is added to events", () => {
session.keychain.register("test-device-id", "password");
session.keychain.register(session.sessionId, "password");
session.keychain.register("test-component", "password");

telemetry.emitEvents([createTestEvent()]);

const calls = mockApiClient.sendEvents.mock.calls;
expect(calls).toHaveLength(1);

// get event properties
const sentEvent = calls[0]?.[0][0] as { properties: Record<string, unknown> };
expectDefined(sentEvent);

const eventProps = sentEvent.properties;
expect(eventProps.device_id).toBe("<password>");
expect(eventProps.session_id).toBe("<password>");
expect(eventProps.component).toBe("<password>");
});
});
});
});
Loading