From 3e548812cb2182fba31e2981b8b7efae8d476c81 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Mon, 31 Aug 2026 17:02:32 +0300 Subject: [PATCH 1/2] DX-2977: identify the cli with Upstash-Telemetry headers The coordinator cannot tell the cli apart from a raw API-key caller: both send only Basic auth. Send the same Upstash-Telemetry-* trio the JS SDKs and @upstash/mcp-server already send, so requests can be attributed. Claude-Session: https://claude.ai/code/session_016dGHwYmqdtkQswYYbrgudB --- src/client.ts | 2 ++ src/telemetry.ts | 13 +++++++++++++ tests/unit/client.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 src/telemetry.ts create mode 100644 tests/unit/client.test.ts diff --git a/src/client.ts b/src/client.ts index f8f1af2..dbd2027 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/telemetry.ts b/src/telemetry.ts new file mode 100644 index 0000000..1983b6b --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,13 @@ +import pkg from "../package.json" with { type: "json" }; + +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}`; +} + +export const telemetryHeaders: Record = { + "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")}`, + ); + }); +}); From 610c051cc129257be0546c984ebd0e5536a54aca Mon Sep 17 00:00:00 2001 From: CahidArda Date: Tue, 1 Sep 2026 10:50:07 +0300 Subject: [PATCH 2/2] DX-2977: let users turn CLI telemetry off Adds the opt-out alongside the headers themselves, following what the other Upstash SDKs and comparable CLIs do: upstash telemetry disable | enable | status persisted, as with `vercel telemetry disable` and `fly settings analytics` UPSTASH_DISABLE_TELEMETRY=1 read by redis-js, qstash-js, vector-js and the rest The env var beats the saved preference, so a CI job opts out without writing config. The headers are now resolved per request instead of at import. `cli.ts` calls dotenv only after the module graph is evaluated, so a value read at import time would miss a `.env` file. The saved preference lives beside the credentials in config.json, so `writeConfig` merges rather than overwrites and `deleteConfig` keeps the preference behind: logging out must not quietly turn telemetry back on. It also now reports whether credentials were actually removed, so logout does not claim one when only the preference was stored. Claude-Session: https://claude.ai/code/session_016dGHwYmqdtkQswYYbrgudB --- README.md | 32 +++++++++ src/cli.ts | 2 + src/client.ts | 2 +- src/commands/telemetry.ts | 38 +++++++++++ src/config.ts | 58 ++++++++++++++--- src/telemetry.ts | 35 ++++++++-- tests/unit/telemetry.test.ts | 122 +++++++++++++++++++++++++++++++++++ 7 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 src/commands/telemetry.ts create mode 100644 tests/unit/telemetry.test.ts 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 dbd2027..5f10354 100644 --- a/src/client.ts +++ b/src/client.ts @@ -23,7 +23,7 @@ export async function request( headers: { Authorization: `Basic ${credentials}`, "Content-Type": "application/json", - ...telemetryHeaders, + ...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 index 1983b6b..7be1674 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -1,4 +1,5 @@ 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}`; @@ -6,8 +7,32 @@ function runtime(): string { return `node@${process.versions.node}`; } -export const telemetryHeaders: Record = { - "Upstash-Telemetry-Sdk": `@upstash/cli@${pkg.version}`, - "Upstash-Telemetry-Runtime": runtime(), - "Upstash-Telemetry-Platform": process.platform, -}; +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/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, + }); + }); +});