diff --git a/PRIVACY.md b/PRIVACY.md index 25db3a8813..2f589cf3ea 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -40,13 +40,6 @@ go—and, importantly, where they don't. We retain telemetry only as long as needed for product analytics and debugging. Telemetry does **not** collect your code or AI prompts, and you can opt out at any time through the settings. -- **Zoo Code Observability (Authenticated Subscribers Only):** If you sign in to - Zoo Code and have an active subscription, Zoo Code will send LLM usage - telemetry to the Zoo Code backend (zoocode.dev). This includes task ID, AI - provider name, model name, token counts (input/output/cache), and estimated - cost. This data is linked to your authenticated Zoo Code account. You can stop - this collection at any time by signing out via the Zoo Code badge in the chat - area. - **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API call to Zoo Code's backend servers to retrieve listing information. These diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 454109778b..1307d88e5a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3013,27 +3013,6 @@ export class Task extends EventEmitter implements TaskLike { cacheReadTokens: tokens.cacheRead, cost: tokens.total ?? costResult.totalCost, }) - - // Zoo Code observability telemetry - import("../../services/zoo-telemetry") - .then(async ({ sendLlmTelemetry }) => { - const mode = await this.getTaskMode().catch(() => "unknown") - return sendLlmTelemetry({ - taskId: this.taskId, - provider: this.apiConfiguration?.apiProvider ?? "unknown", - model: this.apiConfiguration - ? (getModelId(this.apiConfiguration) ?? "unknown") - : "unknown", - mode, - inputTokens: costResult.totalInputTokens, - outputTokens: costResult.totalOutputTokens, - cacheReadTokens: tokens.cacheRead ?? 0, - cacheWriteTokens: tokens.cacheWrite ?? 0, - totalCost: tokens.total ?? costResult.totalCost, - status, - }).catch(() => {}) - }) - .catch(() => {}) } } diff --git a/src/services/__tests__/zoo-code-auth.test.ts b/src/services/__tests__/zoo-code-auth.test.ts index d06d86a226..c08fcdedf3 100644 --- a/src/services/__tests__/zoo-code-auth.test.ts +++ b/src/services/__tests__/zoo-code-auth.test.ts @@ -2,11 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import * as vscode from "vscode" import { - checkSubscriptionStatus, clearZooCodeToken, clearZooCodeUserInfo, disconnectZooCode, - getCachedSubscriptionStatus, getCachedZooCodeToken, getCachedZooCodeUserInfo, getZooCodeBaseUrl, @@ -68,98 +66,6 @@ describe("zoo-code-auth", () => { vi.restoreAllMocks() }) - describe("getCachedSubscriptionStatus", () => { - it("returns unknown initially", () => { - expect(getCachedSubscriptionStatus()).toBe("unknown") - }) - }) - - describe("checkSubscriptionStatus", () => { - it("returns inactive when no token is present", async () => { - await initZooCodeAuth(mockContext) - - const status = await checkSubscriptionStatus() - - expect(status).toBe("inactive") - expect(mockFetch).not.toHaveBeenCalled() - }) - - it("returns active when the API reports an active subscriber", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true, planId: "pro", status: "active" }), - }) - - const status = await checkSubscriptionStatus() - - expect(status).toBe("active") - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/subscription/status"), - expect.objectContaining({ - headers: { Authorization: "Bearer zoo_ext_test_token" }, - }), - ) - }) - - it("returns inactive when the API reports a free user", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: false, planId: "free", status: "active" }), - }) - - await expect(checkSubscriptionStatus()).resolves.toBe("inactive") - }) - - it("returns unknown when the API request fails", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }) - - await expect(checkSubscriptionStatus()).resolves.toBe("unknown") - }) - - it("returns unknown when the API throws", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - mockFetch.mockRejectedValueOnce(new Error("Network error")) - - await expect(checkSubscriptionStatus()).resolves.toBe("unknown") - }) - - it("reuses the cached status when it was checked recently", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true, planId: "pro", status: "active" }), - }) - - expect(await checkSubscriptionStatus()).toBe("active") - expect(await checkSubscriptionStatus()).toBe("active") - expect(mockFetch).toHaveBeenCalledTimes(1) - }) - - it("handles AbortSignal timeouts", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_test_token") - mockFetch.mockRejectedValueOnce(new DOMException("Aborted", "AbortError")) - - await expect(checkSubscriptionStatus()).resolves.toBe("unknown") - }) - }) - describe("getCachedZooCodeToken", () => { it("returns an empty string when no token is set", async () => { await clearZooCodeToken() @@ -169,15 +75,10 @@ describe("zoo-code-auth", () => { it("preloads the cached token during initialization", async () => { await mockSecrets.store("zoo-code-session-token", "zoo_ext_cached_token") - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ valid: true }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true }), - }) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ valid: true }), + }) await initZooCodeAuth(mockContext) await Promise.resolve() @@ -237,10 +138,8 @@ describe("zoo-code-auth", () => { await initZooCodeAuth(mockContext) - // Token and user info should be kept; subscription status should be unknown expect(getCachedZooCodeToken()).toBe("zoo_ext_valid_token") expect(getCachedZooCodeUserInfo().name).toBe("Jane Doe") - expect(getCachedSubscriptionStatus()).toBe("unknown") }) it("preserves token and user info when verify returns 5xx (transient backend error)", async () => { @@ -257,39 +156,16 @@ describe("zoo-code-auth", () => { expect(getCachedZooCodeToken()).toBe("zoo_ext_valid_token") expect(getCachedZooCodeUserInfo().name).toBe("Jane Doe") - expect(getCachedSubscriptionStatus()).toBe("unknown") - }) - }) - - describe("setZooCodeToken", () => { - it("resets the cached subscription status when the token changes", async () => { - await initZooCodeAuth(mockContext) - await setZooCodeToken("zoo_ext_token1") - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true, planId: "pro", status: "active" }), - }) - await checkSubscriptionStatus() - - await setZooCodeToken("zoo_ext_token2") - - expect(getCachedSubscriptionStatus()).toBe("unknown") }) }) describe("clearZooCodeToken", () => { - it("resets the cached subscription status when the token is cleared", async () => { + it("clears the cached token", async () => { await initZooCodeAuth(mockContext) await setZooCodeToken("zoo_ext_test_token") - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true, planId: "pro", status: "active" }), - }) - await checkSubscriptionStatus() await clearZooCodeToken() - expect(getCachedSubscriptionStatus()).toBe("unknown") expect(getCachedZooCodeToken()).toBe("") }) }) @@ -337,15 +213,10 @@ describe("zoo-code-auth", () => { it("persists a token only after backend verification succeeds", async () => { await initZooCodeAuth(mockContext) - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ valid: true }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ isSubscriber: true }), - }) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ valid: true }), + }) const success = await handleAuthCallback("zoo_ext_real_token") diff --git a/src/services/__tests__/zoo-telemetry.test.ts b/src/services/__tests__/zoo-telemetry.test.ts deleted file mode 100644 index 5f5ace80de..0000000000 --- a/src/services/__tests__/zoo-telemetry.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" - -const { - mockCheckSubscriptionStatus, - mockGetCachedSubscriptionStatus, - mockGetCachedZooCodeToken, - mockGetZooCodeBaseUrl, -} = vi.hoisted(() => ({ - mockCheckSubscriptionStatus: vi.fn(), - mockGetCachedSubscriptionStatus: vi.fn(), - mockGetCachedZooCodeToken: vi.fn(), - mockGetZooCodeBaseUrl: vi.fn(), -})) - -vi.mock("../zoo-code-auth", () => ({ - checkSubscriptionStatus: mockCheckSubscriptionStatus, - getCachedSubscriptionStatus: mockGetCachedSubscriptionStatus, - getCachedZooCodeToken: mockGetCachedZooCodeToken, - getZooCodeBaseUrl: mockGetZooCodeBaseUrl, -})) - -import { sendLlmTelemetry } from "../zoo-telemetry" - -describe("sendLlmTelemetry", () => { - const payload = { - taskId: "task-123", - provider: "anthropic", - model: "claude-sonnet-4", - mode: "code", - inputTokens: 11, - outputTokens: 7, - cacheReadTokens: 3, - cacheWriteTokens: 5, - totalCost: 1.23, - } - - beforeEach(() => { - vi.clearAllMocks() - mockGetZooCodeBaseUrl.mockReturnValue("https://www.zoocode.dev") - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("skips telemetry when there is no cached token", async () => { - mockGetCachedZooCodeToken.mockReturnValue("") - global.fetch = vi.fn() - - await sendLlmTelemetry(payload) - - expect(global.fetch).not.toHaveBeenCalled() - }) - - it("refreshes an unknown subscription status before sending", async () => { - mockGetCachedZooCodeToken.mockReturnValue("zoo_ext_test_token") - mockGetCachedSubscriptionStatus.mockReturnValue("unknown") - mockCheckSubscriptionStatus.mockResolvedValue("inactive") - global.fetch = vi.fn() - - await sendLlmTelemetry(payload) - - expect(mockCheckSubscriptionStatus).toHaveBeenCalled() - expect(global.fetch).not.toHaveBeenCalled() - }) - - it("fires the observability request without waiting for it to settle", async () => { - mockGetCachedZooCodeToken.mockReturnValue("zoo_ext_test_token") - mockGetCachedSubscriptionStatus.mockReturnValue("active") - - let resolveFetch: ((value: unknown) => void) | undefined - global.fetch = vi.fn( - () => - new Promise((resolve) => { - resolveFetch = resolve - }), - ) as typeof fetch - - const result = await Promise.race([ - sendLlmTelemetry(payload).then(() => "resolved"), - new Promise((resolve) => setTimeout(() => resolve("timeout"), 20)), - ]) - - expect(result).toBe("resolved") - expect(global.fetch).toHaveBeenCalledWith( - "https://www.zoocode.dev/api/observability/events", - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer zoo_ext_test_token", - }, - signal: expect.any(AbortSignal), - }), - ) - expect(JSON.parse((global.fetch as any).mock.calls[0][1].body)).toMatchObject({ - ...payload, - status: "completed", - editor: "vscode", - }) - - resolveFetch?.({ ok: true }) - }) - - it("sends cancelled status when provided in payload", async () => { - mockGetCachedZooCodeToken.mockReturnValue("zoo_ext_test_token") - mockGetCachedSubscriptionStatus.mockReturnValue("active") - - global.fetch = vi.fn().mockResolvedValue({ ok: true }) - - await sendLlmTelemetry({ ...payload, status: "cancelled" }) - - expect(JSON.parse((global.fetch as any).mock.calls[0][1].body)).toMatchObject({ - ...payload, - status: "cancelled", - editor: "vscode", - }) - }) - - it("defaults to completed status when not provided", async () => { - mockGetCachedZooCodeToken.mockReturnValue("zoo_ext_test_token") - mockGetCachedSubscriptionStatus.mockReturnValue("active") - - global.fetch = vi.fn().mockResolvedValue({ ok: true }) - - await sendLlmTelemetry(payload) - - expect(JSON.parse((global.fetch as any).mock.calls[0][1].body)).toMatchObject({ - status: "completed", - }) - }) -}) diff --git a/src/services/zoo-code-auth.ts b/src/services/zoo-code-auth.ts index f7587aaf80..709cb805c3 100644 --- a/src/services/zoo-code-auth.ts +++ b/src/services/zoo-code-auth.ts @@ -15,9 +15,6 @@ let _sessionCleared = false let _cachedUserName: string | undefined = undefined let _cachedUserEmail: string | undefined = undefined let _cachedUserImage: string | undefined = undefined -let _cachedSubscriptionStatus: "active" | "inactive" | "unknown" = "unknown" -let _lastSubscriptionCheck: number = 0 -const SUBSCRIPTION_CHECK_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes export async function initZooCodeAuth(context: vscode.ExtensionContext): Promise { if (!context.secrets) { @@ -35,19 +32,13 @@ export async function initZooCodeAuth(context: vscode.ExtensionContext): Promise _cachedUserImage = await secretStorage.get(ZOO_CODE_USER_IMAGE_KEY) // Validate persisted auth state on init before reporting the user as connected. + // Network errors / 5xx ("unreachable") leave the cached session in place so a + // transient backend blip doesn't force users to sign in again. if (_cachedToken) { const result = await verifyZooCodeToken() if (result === "invalid") { - // Token is definitively rejected by the backend — clear everything. await clearZooCodeUserInfo() await clearZooCodeToken() - } else if (result === "unreachable") { - // Network is temporarily down; keep the cached session but mark subscription - // status as unknown so callers know it hasn't been confirmed. - _cachedSubscriptionStatus = "unknown" - } else { - // result === "valid" - void checkSubscriptionStatus().catch(() => {}) } } @@ -56,12 +47,6 @@ export async function initZooCodeAuth(context: vscode.ExtensionContext): Promise if (e.key === ZOO_CODE_TOKEN_KEY) { secretStorage?.get(ZOO_CODE_TOKEN_KEY).then((token) => { _cachedToken = token - // Reset subscription status when token changes - _cachedSubscriptionStatus = "unknown" - _lastSubscriptionCheck = 0 - if (token) { - checkSubscriptionStatus().catch(() => {}) - } }) } if (e.key === ZOO_CODE_USER_NAME_KEY) { @@ -110,57 +95,6 @@ export function getCachedZooCodeUserInfo(): { name?: string; email?: string; ima } } -/** - * Get the cached subscription status. This is a synchronous getter that returns - * the last known subscription status. Call checkSubscriptionStatus() to refresh. - */ -export function getCachedSubscriptionStatus(): "active" | "inactive" | "unknown" { - return _cachedSubscriptionStatus -} - -/** - * Check the subscription status from the backend API. - * Updates the cached status and returns it. - * Implements caching to avoid excessive API calls (5 minute cache). - */ -export async function checkSubscriptionStatus(): Promise<"active" | "inactive" | "unknown"> { - const token = await getZooCodeToken() - if (!token) { - _cachedSubscriptionStatus = "inactive" - return "inactive" - } - - // Return cached status if checked recently - const now = Date.now() - if (now - _lastSubscriptionCheck < SUBSCRIPTION_CHECK_INTERVAL_MS && _cachedSubscriptionStatus !== "unknown") { - return _cachedSubscriptionStatus - } - - const baseUrl = getZooCodeBaseUrl() - - try { - const response = await fetch(`${baseUrl}/api/subscription/status`, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(10_000), - }) - - if (!response.ok) { - _cachedSubscriptionStatus = "unknown" - _lastSubscriptionCheck = now - return "unknown" - } - - const data = (await response.json()) as { isSubscriber?: boolean } - _cachedSubscriptionStatus = data.isSubscriber ? "active" : "inactive" - _lastSubscriptionCheck = now - return _cachedSubscriptionStatus - } catch { - _cachedSubscriptionStatus = "unknown" - _lastSubscriptionCheck = now - return "unknown" - } -} - export async function getZooCodeToken(): Promise { if (!secretStorage) return undefined return secretStorage.get(ZOO_CODE_TOKEN_KEY) @@ -171,9 +105,6 @@ export async function setZooCodeToken(token: string): Promise { await secretStorage.store(ZOO_CODE_TOKEN_KEY, token) _cachedToken = token _sessionCleared = false - // Reset subscription status when token is set - _cachedSubscriptionStatus = "unknown" - _lastSubscriptionCheck = 0 } export async function setZooCodeUserInfo(info: { @@ -223,8 +154,6 @@ export async function clearZooCodeToken(): Promise { await secretStorage.delete(ZOO_CODE_TOKEN_KEY) _cachedToken = undefined _sessionCleared = true - _cachedSubscriptionStatus = "unknown" - _lastSubscriptionCheck = 0 } export function getZooCodeBaseUrl(): string { @@ -266,9 +195,6 @@ export async function handleAuthCallback(token: string): Promise { await setZooCodeToken(token) - // Check subscription status after successful auth - await checkSubscriptionStatus().catch(() => {}) - vscode.window.showInformationMessage(t("common:zooAuth.info.connected")) return true } diff --git a/src/services/zoo-telemetry.ts b/src/services/zoo-telemetry.ts deleted file mode 100644 index b181ff6d5b..0000000000 --- a/src/services/zoo-telemetry.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - getCachedZooCodeToken, - getZooCodeBaseUrl, - getCachedSubscriptionStatus, - checkSubscriptionStatus, -} from "./zoo-code-auth" -import { Package } from "../shared/package" - -export type LlmTelemetryPayload = { - taskId: string - provider: string - model: string - mode?: string - inputTokens: number - outputTokens: number - cacheReadTokens?: number - cacheWriteTokens?: number - totalCost?: number - status?: "completed" | "cancelled" -} - -/** - * Send LLM telemetry to the Zoo Code observability backend. - * This is a fire-and-forget operation that silently fails on error. - * Only sends telemetry for authenticated users with active subscriptions. - */ -export async function sendLlmTelemetry(payload: LlmTelemetryPayload): Promise { - const token = getCachedZooCodeToken() - if (!token) { - return - } - - // Check subscription status before sending (uses 5-minute cache) - let status = getCachedSubscriptionStatus() - if (status === "unknown") { - status = await checkSubscriptionStatus().catch(() => "unknown" as const) - } - - if (status !== "active") { - return - } - - const baseUrl = getZooCodeBaseUrl() - - const body = { - ...payload, - status: payload.status ?? "completed", - extensionVersion: Package.version, - editor: "vscode", - } - - void fetch(`${baseUrl}/api/observability/events`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(10_000), - }).catch(() => { - // Silently ignore errors - telemetry should never impact user experience - }) -}