Skip to content

Commit e520fc5

Browse files
authored
Add privacy-safe telemetry to the CLI (#1951)
1 parent 1f7f49c commit e520fc5

16 files changed

Lines changed: 1380 additions & 17 deletions

cli/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Options:
2828
--timeout <ms> Request timeout in milliseconds (default: 30000)
2929
--rpc Include RPC logs in JSON output
3030
--quiet Suppress non-result progress output
31+
--no-telemetry Disable anonymous usage telemetry
3132
--format <format> Output format
3233
-h, --help display help for command
3334
@@ -40,6 +41,7 @@ Commands:
4041
oauth Run MCP OAuth login, proxy, and conformance flows
4142
protocol MCP protocol inspection and conformance checks
4243
inspector Start or attach to the local MCPJam Inspector
44+
telemetry Inspect and configure anonymous CLI telemetry
4345
```
4446

4547
## Quick start
@@ -161,6 +163,29 @@ echo '{"query":"setup guide"}' | mcpjam tools call --url $URL --access-token $TO
161163

162164
Use `--format json|human` for the raw command result. Use `--reporter json-summary|junit-xml` on conformance and diff commands when CI needs a report artifact. `server validate` uses `--debug-out` for validation artifacts.
163165

166+
## Telemetry
167+
168+
`mcpjam` collects anonymous command-level telemetry so we can understand CLI usage and reliability. Events include the command/subcommand name, success/failure, exit code, duration, CLI version, Node version, OS, CPU architecture, transport type (`http` or `stdio`), `platform: "cli"`, and coarse CI metadata (`is_ci` and a provider enum such as `github_actions`).
169+
170+
Telemetry is enabled by default. The first command invocation that is not opted out writes `telemetry.json` with `enabled: true` and a random install UUID.
171+
172+
Telemetry uses a random install UUID stored at the same platform cache location as update checks, in `telemetry.json`. It does not collect raw argv, URLs, hostnames, ports, tokens, headers, environment values, working directories, file paths, tool/resource/prompt names, error messages, stack traces, repository names, branch names, workflow names, or CI job ids.
173+
174+
Disable telemetry for one invocation with `--no-telemetry`, or persistently with:
175+
176+
```bash
177+
mcpjam telemetry disable
178+
```
179+
180+
Check or re-enable it with:
181+
182+
```bash
183+
mcpjam telemetry status
184+
mcpjam telemetry enable
185+
```
186+
187+
Set `DO_NOT_TRACK=1` or `MCPJAM_TELEMETRY_DISABLED=1` to disable telemetry through the environment. Set `MCPJAM_TELEMETRY_DEBUG=1` to print the sanitized telemetry payload to stderr instead of sending it.
188+
164189
## GitHub Actions
165190

166191
```yaml

cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
},
1818
"dependencies": {
1919
"@mcpjam/sdk": "^1.0.0",
20-
"commander": "^12.1.0"
20+
"commander": "^12.1.0",
21+
"posthog-node": "^5.24.10"
2122
},
2223
"devDependencies": {
2324
"@types/node": "^24.0.0",

cli/src/commands/telemetry.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Command } from "commander";
2+
import { writeResult, type OutputFormat } from "../lib/output.js";
3+
import { getGlobalOptions } from "../lib/server-config.js";
4+
import {
5+
formatTelemetryStatusHuman,
6+
getTelemetryStatus,
7+
setTelemetryEnabled,
8+
type TelemetryOptions,
9+
} from "../lib/telemetry.js";
10+
11+
interface TelemetryCommandOptions {
12+
telemetry?: boolean;
13+
}
14+
15+
export function registerTelemetryCommands(
16+
program: Command,
17+
telemetryOptions: TelemetryOptions = {},
18+
): void {
19+
const telemetry = program
20+
.command("telemetry")
21+
.description("Inspect and configure anonymous CLI telemetry");
22+
23+
telemetry
24+
.command("status")
25+
.description("Show the current telemetry status")
26+
.action((_options, command) => {
27+
writeTelemetryStatus(
28+
resolveTelemetryStatus(command, telemetryOptions),
29+
getGlobalOptions(command).format,
30+
);
31+
});
32+
33+
telemetry
34+
.command("disable")
35+
.description("Disable anonymous CLI telemetry")
36+
.action((_options, command) => {
37+
setTelemetryEnabled(false, telemetryOptions);
38+
writeTelemetryStatus(
39+
resolveTelemetryStatus(command, telemetryOptions),
40+
getGlobalOptions(command).format,
41+
);
42+
});
43+
44+
telemetry
45+
.command("enable")
46+
.description("Enable anonymous CLI telemetry")
47+
.action((_options, command) => {
48+
setTelemetryEnabled(true, telemetryOptions);
49+
writeTelemetryStatus(
50+
resolveTelemetryStatus(command, telemetryOptions),
51+
getGlobalOptions(command).format,
52+
);
53+
});
54+
}
55+
56+
function resolveTelemetryStatus(
57+
command: Command,
58+
telemetryOptions: TelemetryOptions,
59+
) {
60+
const options = command.optsWithGlobals() as TelemetryCommandOptions;
61+
return getTelemetryStatus({
62+
...telemetryOptions,
63+
commandOptOut: options.telemetry === false,
64+
});
65+
}
66+
67+
function writeTelemetryStatus(
68+
status: ReturnType<typeof getTelemetryStatus>,
69+
format: OutputFormat,
70+
): void {
71+
if (format === "human") {
72+
process.stdout.write(formatTelemetryStatusHuman(status));
73+
return;
74+
}
75+
76+
writeResult(
77+
{
78+
success: true,
79+
telemetry: status,
80+
},
81+
format,
82+
);
83+
}

cli/src/index.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { registerOAuthCommands } from "./commands/oauth.js";
88
import { registerPromptCommands } from "./commands/prompts.js";
99
import { registerResourcesCommands } from "./commands/resources.js";
1010
import { registerServerCommands } from "./commands/server.js";
11+
import { registerTelemetryCommands } from "./commands/telemetry.js";
1112
import { registerToolsCommands } from "./commands/tools.js";
1213
import { registerInspectorCommands } from "./commands/inspector.js";
1314
import {
@@ -17,6 +18,11 @@ import {
1718
writeError,
1819
} from "./lib/output.js";
1920
import { addGlobalOptions } from "./lib/server-config.js";
21+
import {
22+
captureCommandEvent,
23+
initTelemetry,
24+
type TelemetryOptions,
25+
} from "./lib/telemetry.js";
2026
import { checkForUpdates } from "./lib/update-notifier.js";
2127

2228
const pkgVersion = packageJson.version;
@@ -26,12 +32,17 @@ export interface CliMainResult {
2632
shouldCheckForUpdates: boolean;
2733
}
2834

29-
export interface CliEntrypointDependencies {
35+
export interface CliMainDependencies {
36+
telemetry?: TelemetryOptions;
37+
}
38+
39+
export interface CliEntrypointDependencies extends CliMainDependencies {
3040
checkForUpdates?: (currentVersion: string) => void;
3141
}
3242

3343
export async function main(
3444
argv: readonly string[] = process.argv,
45+
dependencies: CliMainDependencies = {},
3546
): Promise<CliMainResult> {
3647
const program = addGlobalOptions(
3748
new Command()
@@ -49,6 +60,7 @@ export async function main(
4960
},
5061
}),
5162
);
63+
const telemetry = initTelemetry(program, pkgVersion, dependencies.telemetry);
5264

5365
registerServerCommands(program);
5466
registerToolsCommands(program);
@@ -58,6 +70,7 @@ export async function main(
5870
registerOAuthCommands(program);
5971
registerProtocolCommands(program);
6072
registerInspectorCommands(program);
73+
registerTelemetryCommands(program, dependencies.telemetry);
6174

6275
if (argv.length <= 2) {
6376
program.outputHelp();
@@ -69,16 +82,15 @@ export async function main(
6982

7083
try {
7184
await program.parseAsync(argv as string[]);
72-
const exitCode = process.exitCode;
73-
if (typeof exitCode === "number") {
74-
return {
75-
exitCode,
76-
shouldCheckForUpdates: true,
77-
};
78-
}
79-
85+
const normalizedExitCode =
86+
typeof process.exitCode === "number" ? process.exitCode : 0;
87+
captureCommandEvent(
88+
normalizedExitCode,
89+
normalizedExitCode === 0 ? undefined : "UNKNOWN_ERROR",
90+
);
91+
await telemetry.flush();
8092
return {
81-
exitCode: Number(exitCode ?? 0) || 0,
93+
exitCode: normalizedExitCode,
8294
shouldCheckForUpdates: true,
8395
};
8496
} catch (error) {
@@ -89,13 +101,16 @@ export async function main(
89101
error.code === "commander.helpDisplayed" ||
90102
error.code === "commander.version"
91103
) {
104+
await telemetry.flush();
92105
return {
93106
exitCode: 0,
94107
shouldCheckForUpdates: false,
95108
};
96109
}
97110

98111
writeError(usageError(error.message), format);
112+
captureCommandEvent(2, "USAGE_ERROR");
113+
await telemetry.flush();
99114
return {
100115
exitCode: 2,
101116
shouldCheckForUpdates: false,
@@ -104,6 +119,8 @@ export async function main(
104119

105120
const normalizedError = normalizeCliError(error);
106121
writeError(normalizedError, format);
122+
captureCommandEvent(normalizedError.exitCode, normalizedError.code);
123+
await telemetry.flush();
107124
return {
108125
exitCode: normalizedError.exitCode,
109126
shouldCheckForUpdates: false,
@@ -115,7 +132,7 @@ export async function runCliEntrypoint(
115132
argv: readonly string[] = process.argv,
116133
dependencies: CliEntrypointDependencies = {},
117134
): Promise<CliMainResult> {
118-
const result = await main(argv);
135+
const result = await main(argv, dependencies);
119136
process.exitCode = result.exitCode;
120137

121138
if (result.exitCode === 0 && result.shouldCheckForUpdates) {

cli/src/lib/server-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface GlobalOptions {
1616
timeout: number;
1717
rpc: boolean;
1818
quiet: boolean;
19+
telemetry: boolean;
1920
}
2021

2122
type TransportType = "http" | "stdio";
@@ -109,6 +110,7 @@ export function getGlobalOptions(command: Command): GlobalOptions {
109110
timeout: options.timeout ?? 30_000,
110111
rpc: options.rpc ?? false,
111112
quiet: options.quiet ?? false,
113+
telemetry: options.telemetry ?? true,
112114
};
113115
}
114116

@@ -370,6 +372,7 @@ export function addGlobalOptions(program: Command): Command {
370372
)
371373
.option("--rpc", "Include RPC logs in JSON output")
372374
.option("--quiet", "Suppress non-result progress output")
375+
.option("--no-telemetry", "Disable anonymous usage telemetry")
373376
.option("--format <format>", "Output format");
374377
}
375378

0 commit comments

Comments
 (0)