diff --git a/README.md b/README.md index 4da8779..632519b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,38 @@ upstash team add-member --team-id $TEAM_ID --member-email you@example.com --role Run `upstash --help` (or `--help` on any subcommand) to discover everything else, and check the [full docs](https://upstash.com/docs/agent-resources/cli) for the complete catalog. +## Telemetry + +The CLI identifies itself to the Upstash API on each request, so we can see which +clients our endpoints are serving. It sends three headers and nothing else: + +| Header | Example | +| --- | --- | +| `Upstash-Telemetry-Sdk` | `@upstash/cli@1.2.0` | +| `Upstash-Telemetry-Runtime` | `node@22.14.0` | +| `Upstash-Telemetry-Platform` | `darwin` | + +That is the CLI version, the JS runtime, and the OS platform. No command +arguments, credentials, resource names, or file paths are collected. + +To turn it off: + +```bash +upstash telemetry disable # saved to your config file +upstash telemetry status # check the current setting +upstash telemetry enable # turn it back on +``` + +Or set the environment variable every Upstash SDK honors, which also works from +a `.env` file and takes precedence over the saved setting: + +```bash +export UPSTASH_DISABLE_TELEMETRY=1 +``` + +Disabling telemetry never affects what the CLI can do. `upstash logout` keeps +the setting, so signing out does not quietly turn it back on. + ## Contributing ```bash diff --git a/src/cli.ts b/src/cli.ts index 0358e86..e6a8a1b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { registerQStash } from "./commands/qstash/index.js"; import { registerLogin } from "./commands/login.js"; import { registerLogout } from "./commands/logout.js"; import { registerStartRedis } from "./commands/start-redis.js"; +import { registerTelemetry } from "./commands/telemetry.js"; import { handleError } from "./output.js"; import dotenv from "dotenv"; @@ -41,6 +42,7 @@ program registerLogin(program); registerLogout(program); registerStartRedis(program); +registerTelemetry(program); registerRedis(program); registerTeam(program); registerVector(program); diff --git a/src/client.ts b/src/client.ts index f8f1af2..5f10354 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,5 @@ import type { Auth } from "./auth.js"; +import { telemetryHeaders } from "./telemetry.js"; const BASE_URL = "https://api.upstash.com"; @@ -22,6 +23,7 @@ export async function request( headers: { Authorization: `Basic ${credentials}`, "Content-Type": "application/json", + ...telemetryHeaders(), }, body: body !== undefined ? JSON.stringify(body) : undefined, }); diff --git a/src/commands/telemetry.ts b/src/commands/telemetry.ts new file mode 100644 index 0000000..5fc59e7 --- /dev/null +++ b/src/commands/telemetry.ts @@ -0,0 +1,38 @@ +import { Command } from "commander"; +import { writeTelemetryDisabled } from "../config.js"; +import { telemetryStatus } from "../telemetry.js"; +import { printJSON } from "../output.js"; + +export function registerTelemetry(program: Command): void { + const telemetry = program + .command("telemetry") + .description( + "Show or change whether the CLI identifies itself to Upstash. It sends the CLI version, the JS runtime, and the OS platform — never command arguments, credentials, or resource names.", + ); + + telemetry + .command("status") + .description("Report whether telemetry is enabled, and what turned it off") + .action(() => { + printJSON(telemetryStatus()); + }); + + telemetry + .command("disable") + .description("Stop sending telemetry headers, saved to the user config file") + .action(() => { + const path = writeTelemetryDisabled(true); + console.log(`Telemetry disabled, saved to ${path}`); + }); + + telemetry + .command("enable") + .description("Resume sending telemetry headers, saved to the user config file") + .action(() => { + const path = writeTelemetryDisabled(false); + console.log(`Telemetry enabled, saved to ${path}`); + if (!telemetryStatus().enabled) { + console.log("Still disabled by UPSTASH_DISABLE_TELEMETRY: unset it to take effect."); + } + }); +} diff --git a/src/config.ts b/src/config.ts index 077f66f..efe3001 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,8 +6,11 @@ import type { Auth } from "./auth.js"; interface StoredConfig { email?: string; api_key?: string; + telemetry_disabled?: boolean; } +type RawConfig = StoredConfig & { apiKey?: string }; + export function getConfigDir(): string { const override = process.env.UPSTASH_CONFIG_HOME; if (override) return override; @@ -29,7 +32,7 @@ export function getLegacyConfigPath(): string { return join(base, ".upstash.json"); } -function readConfigFile(path: string): Auth | null { +function readRawConfig(path: string): RawConfig | null { if (!existsSync(path)) return null; let raw: string; try { @@ -37,12 +40,16 @@ function readConfigFile(path: string): Auth | null { } catch { return null; } - let parsed: StoredConfig & { apiKey?: string }; try { - parsed = JSON.parse(raw) as StoredConfig & { apiKey?: string }; + return JSON.parse(raw) as RawConfig; } catch { return null; } +} + +function readConfigFile(path: string): Auth | null { + const parsed = readRawConfig(path); + if (!parsed) return null; // Accept the new snake_case `api_key` or the legacy camelCase `apiKey`. const apiKey = parsed.api_key ?? parsed.apiKey; if (!parsed.email || !apiKey) return null; @@ -53,17 +60,52 @@ export function readConfig(): Auth | null { return readConfigFile(getConfigPath()) ?? readConfigFile(getLegacyConfigPath()); } -export function writeConfig(auth: Auth): string { +function writeStoredConfig(body: StoredConfig): string { const path = getConfigPath(); mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - const body: StoredConfig = { email: auth.email, api_key: auth.apiKey }; writeFileSync(path, JSON.stringify(body, null, 2) + "\n", { mode: 0o600 }); return path; } +export function writeConfig(auth: Auth): string { + const existing = readRawConfig(getConfigPath()); + return writeStoredConfig({ + email: auth.email, + api_key: auth.apiKey, + ...(existing?.telemetry_disabled === undefined + ? {} + : { telemetry_disabled: existing.telemetry_disabled }), + }); +} + +export function readTelemetryDisabled(): boolean { + return readRawConfig(getConfigPath())?.telemetry_disabled === true; +} + +export function writeTelemetryDisabled(disabled: boolean): string { + const existing = readRawConfig(getConfigPath()); + return writeStoredConfig({ + ...(existing?.email === undefined ? {} : { email: existing.email }), + ...(existing?.api_key ?? existing?.apiKey + ? { api_key: existing.api_key ?? existing.apiKey } + : {}), + telemetry_disabled: disabled, + }); +} + +/** + * Drops the credentials, keeping any telemetry preference: logging out must not + * silently turn telemetry back on. Returns whether credentials were there. + */ export function deleteConfig(): boolean { const path = getConfigPath(); - if (!existsSync(path)) return false; - rmSync(path); - return true; + const existing = readRawConfig(path); + if (!existing) return false; + const hadCredentials = Boolean(existing.email && (existing.api_key ?? existing.apiKey)); + if (existing.telemetry_disabled === undefined) { + rmSync(path); + } else { + writeStoredConfig({ telemetry_disabled: existing.telemetry_disabled }); + } + return hadCredentials; } diff --git a/src/telemetry.ts b/src/telemetry.ts new file mode 100644 index 0000000..7be1674 --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,38 @@ +import pkg from "../package.json" with { type: "json" }; +import { getConfigPath, readTelemetryDisabled } from "./config.js"; + +function runtime(): string { + if (process.versions.bun) return `bun@${process.versions.bun}`; + if (process.versions.deno) return `deno@${process.versions.deno}`; + return `node@${process.versions.node}`; +} + +function isSet(value: string | undefined): boolean { + return value !== undefined && value !== "" && value !== "0" && value !== "false"; +} + +/** + * `UPSTASH_DISABLE_TELEMETRY` is the variable the Upstash SDKs already read. It + * wins over the saved preference so a CI job can opt out without writing config. + */ +export function telemetryStatus(): { enabled: boolean; disabled_by?: string } { + if (isSet(process.env.UPSTASH_DISABLE_TELEMETRY)) { + return { enabled: false, disabled_by: "UPSTASH_DISABLE_TELEMETRY" }; + } + if (readTelemetryDisabled()) return { enabled: false, disabled_by: getConfigPath() }; + return { enabled: true }; +} + +/** + * Resolved per request rather than at import: `cli.ts` loads the .env file + * after the module graph is already evaluated, so an env var read at import + * time would miss it. + */ +export function telemetryHeaders(): Record { + if (!telemetryStatus().enabled) return {}; + return { + "Upstash-Telemetry-Sdk": `@upstash/cli@${pkg.version}`, + "Upstash-Telemetry-Runtime": runtime(), + "Upstash-Telemetry-Platform": process.platform, + }; +} diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts new file mode 100644 index 0000000..c99367d --- /dev/null +++ b/tests/unit/client.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { request } from "../../src/client.js"; + +const auth = { email: "user@example.com", apiKey: "key" }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("request", () => { + it("identifies the cli through the telemetry headers", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("{}", { status: 200 })); + + await request(auth, "GET", "/v2/redis/databases"); + + const headers = fetchMock.mock.calls[0]![1]!.headers as Record; + expect(headers["Upstash-Telemetry-Sdk"]).toMatch(/^@upstash\/cli@/); + expect(headers["Upstash-Telemetry-Runtime"]).toBeTruthy(); + expect(headers["Upstash-Telemetry-Platform"]).toBeTruthy(); + expect(headers.Authorization).toBe( + `Basic ${Buffer.from("user@example.com:key").toString("base64")}`, + ); + }); +}); diff --git a/tests/unit/telemetry.test.ts b/tests/unit/telemetry.test.ts new file mode 100644 index 0000000..a449f0d --- /dev/null +++ b/tests/unit/telemetry.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command } from "commander"; +import { readConfig, writeConfig, deleteConfig, getConfigPath } from "../../src/config.js"; +import { telemetryHeaders, telemetryStatus } from "../../src/telemetry.js"; +import { request } from "../../src/client.js"; +import { registerTelemetry } from "../../src/commands/telemetry.js"; + +const auth = { email: "user@example.com", apiKey: "key" }; + +let dir: string; +const originalEnv = { ...process.env }; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "upstash-cli-telemetry-")); + process.env.UPSTASH_CONFIG_HOME = dir; + process.env.UPSTASH_LEGACY_CONFIG_HOME = dir; + delete process.env.UPSTASH_DISABLE_TELEMETRY; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; + vi.restoreAllMocks(); +}); + +async function run(argv: string[]): Promise { + const output: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => output.push(args.join(" ")); + try { + const program = new Command().exitOverride(); + registerTelemetry(program); + await program.parseAsync(["node", "upstash", ...argv]); + } finally { + console.log = origLog; + } + return output; +} + +describe("telemetryStatus", () => { + it("is enabled by default", () => { + expect(telemetryStatus()).toEqual({ enabled: true }); + expect(telemetryHeaders()["Upstash-Telemetry-Sdk"]).toMatch(/^@upstash\/cli@/); + }); + + it("is disabled by UPSTASH_DISABLE_TELEMETRY", () => { + process.env.UPSTASH_DISABLE_TELEMETRY = "1"; + expect(telemetryStatus()).toEqual({ + enabled: false, + disabled_by: "UPSTASH_DISABLE_TELEMETRY", + }); + expect(telemetryHeaders()).toEqual({}); + }); + + it.each(["", "0", "false"])("ignores a %s env value", (value) => { + process.env.UPSTASH_DISABLE_TELEMETRY = value; + expect(telemetryStatus().enabled).toBe(true); + }); + + it("is disabled by the saved preference", async () => { + await run(["telemetry", "disable"]); + expect(telemetryStatus()).toEqual({ enabled: false, disabled_by: getConfigPath() }); + expect(telemetryHeaders()).toEqual({}); + }); + + it("sends no telemetry headers on a request when disabled", async () => { + await run(["telemetry", "disable"]); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("{}", { status: 200 })); + + await request(auth, "GET", "/v2/redis/databases"); + + const headers = fetchMock.mock.calls[0]![1]!.headers as Record; + expect(Object.keys(headers).some((key) => key.startsWith("Upstash-Telemetry"))).toBe(false); + expect(headers.Authorization).toBeTruthy(); + }); + + it("re-enables through the command", async () => { + await run(["telemetry", "disable"]); + await run(["telemetry", "enable"]); + expect(telemetryStatus().enabled).toBe(true); + }); + + it("reports an env override as still disabled after enable", async () => { + process.env.UPSTASH_DISABLE_TELEMETRY = "1"; + const output = await run(["telemetry", "enable"]); + expect(output.join(" ")).toContain("Still disabled by UPSTASH_DISABLE_TELEMETRY"); + }); +}); + +describe("the telemetry preference and credentials are independent", () => { + it("survives login", async () => { + await run(["telemetry", "disable"]); + writeConfig(auth); + expect(readConfig()).toEqual(auth); + expect(telemetryStatus().enabled).toBe(false); + }); + + it("survives logout", async () => { + writeConfig(auth); + await run(["telemetry", "disable"]); + expect(deleteConfig()).toBe(true); + expect(readConfig()).toBeNull(); + expect(telemetryStatus().enabled).toBe(false); + }); + + it("does not report a logout when only the preference was stored", async () => { + await run(["telemetry", "disable"]); + expect(deleteConfig()).toBe(false); + }); + + it("keeps the config file readable only by the owner", async () => { + await run(["telemetry", "disable"]); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual({ + telemetry_disabled: true, + }); + }); +});