Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -41,6 +42,7 @@ program
registerLogin(program);
registerLogout(program);
registerStartRedis(program);
registerTelemetry(program);
registerRedis(program);
registerTeam(program);
registerVector(program);
Expand Down
2 changes: 2 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Auth } from "./auth.js";
import { telemetryHeaders } from "./telemetry.js";

const BASE_URL = "https://api.upstash.com";

Expand All @@ -22,6 +23,7 @@ export async function request<T>(
headers: {
Authorization: `Basic ${credentials}`,
"Content-Type": "application/json",
...telemetryHeaders(),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
Expand Down
38 changes: 38 additions & 0 deletions src/commands/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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.");
}
});
}
58 changes: 50 additions & 8 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,20 +32,24 @@ 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 {
raw = readFileSync(path, "utf8");
} 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;
Expand All @@ -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;
Comment on lines 100 to +110
}
38 changes: 38 additions & 0 deletions src/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
if (!telemetryStatus().enabled) return {};
return {
"Upstash-Telemetry-Sdk": `@upstash/cli@${pkg.version}`,
"Upstash-Telemetry-Runtime": runtime(),
"Upstash-Telemetry-Platform": process.platform,
};
}
26 changes: 26 additions & 0 deletions tests/unit/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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")}`,
);
});
});
122 changes: 122 additions & 0 deletions tests/unit/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<string, string>;
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,
});
});
});
Loading