diff --git a/apps/cli/src/next/commands/telemetry/telemetry.command.ts b/apps/cli/src/next/commands/telemetry/telemetry.command.ts index 6b4a908fcb..48f07b80cd 100644 --- a/apps/cli/src/next/commands/telemetry/telemetry.command.ts +++ b/apps/cli/src/next/commands/telemetry/telemetry.command.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { Command } from "effect/unstable/cli"; import { Output } from "../../../shared/output/output.service.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -57,7 +57,10 @@ const telemetryStatus = Effect.gen(function* () { yield* output.success(`Telemetry is ${effectiveConsent}.`, { consent: effectiveConsent, config_path: `${configDir}/telemetry.json`, - persisted_consent: config?.consent ?? null, + persisted_consent: Option.match(config, { + onNone: () => null, + onSome: (value) => value.consent, + }), }); }); diff --git a/apps/cli/src/shared/telemetry/consent.ts b/apps/cli/src/shared/telemetry/consent.ts index 3efe7f4582..1f1bd235f1 100644 --- a/apps/cli/src/shared/telemetry/consent.ts +++ b/apps/cli/src/shared/telemetry/consent.ts @@ -1,15 +1,15 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { CliConfig } from "../../next/config/cli-config.service.ts"; -import type { ConsentState, TelemetryConfig } from "./types.ts"; +import { TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; export const getConfigDir = CliConfig.useSync((cliConfig) => cliConfig.supabaseHome); -function parseTelemetryConfig(content: string): TelemetryConfig | null { - try { - return JSON.parse(content) as TelemetryConfig; - } catch { - return null; - } +const TelemetryConfigFileSchema = Schema.fromJsonString(TelemetryConfigSchema); +const decodeTelemetryConfigFile = Schema.decodeUnknownEffect(TelemetryConfigFileSchema); +const encodeTelemetryConfig = Schema.encodeUnknownSync(TelemetryConfigSchema); + +function encodePrettyJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; } export const readTelemetryConfig = Effect.fnUntraced( @@ -18,11 +18,12 @@ export const readTelemetryConfig = Effect.fnUntraced( const path = yield* Path.Path; const configPath = path.join(configDir, "telemetry.json"); const exists = yield* fs.exists(configPath); - if (!exists) return null; + if (!exists) return Option.none(); const content = yield* fs.readFileString(configPath); - return parseTelemetryConfig(content); + const config = yield* decodeTelemetryConfigFile(content); + return Option.some(config); }, - (effect) => Effect.orElseSucceed(effect, () => null), + (effect) => Effect.orElseSucceed(effect, () => Option.none()), ); export const writeTelemetryConfig = Effect.fnUntraced(function* ( @@ -34,19 +35,26 @@ export const writeTelemetryConfig = Effect.fnUntraced(function* ( yield* fs.makeDirectory(configDir, { recursive: true, mode: 0o700 }); const configPath = path.join(configDir, "telemetry.json"); const tmpPath = `${configPath}.tmp.${Date.now()}`; - yield* fs.writeFileString(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + yield* fs.writeFileString(tmpPath, encodePrettyJson(encodeTelemetryConfig(config)), { + mode: 0o600, + }); yield* fs.rename(tmpPath, configPath); }, Effect.orDie); -export const getEffectiveConsent = Effect.fnUntraced(function* (config: TelemetryConfig | null) { +export const getEffectiveConsent = Effect.fnUntraced(function* ( + config: Option.Option, +) { const cliConfig = yield* CliConfig; const telemetryDisabled = cliConfig.telemetryDisabled; if (Option.isSome(telemetryDisabled) && telemetryDisabled.value === "1") { - return "denied" as ConsentState; + return "denied" as const; } const doNotTrack = cliConfig.doNotTrack; - if (Option.isSome(doNotTrack) && doNotTrack.value === "1") return "denied" as ConsentState; + if (Option.isSome(doNotTrack) && doNotTrack.value === "1") return "denied" as const; - return (config?.consent ?? "granted") as ConsentState; + return Option.match(config, { + onNone: () => "granted" as const, + onSome: (value) => value.consent, + }); }); diff --git a/apps/cli/src/shared/telemetry/consent.unit.test.ts b/apps/cli/src/shared/telemetry/consent.unit.test.ts index 1ab9dbe5b3..adb4d63d30 100644 --- a/apps/cli/src/shared/telemetry/consent.unit.test.ts +++ b/apps/cli/src/shared/telemetry/consent.unit.test.ts @@ -3,7 +3,7 @@ import { BunServices } from "@effect/platform-bun"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Option } from "effect"; import { cliConfigLayer } from "../../next/config/cli-config.layer.ts"; import { mockProjectContext, @@ -55,62 +55,89 @@ function writeTelemetryFile(dir: string, content: string): void { describe("getEffectiveConsent", () => { it.live("returns denied when DO_NOT_TRACK=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(makeConfig("granted")); + const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ DO_NOT_TRACK: "1" }))), ); it.live("returns denied when SUPABASE_TELEMETRY_DISABLED=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(makeConfig("granted")); + const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1" }))), ); it.live("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over persisted granted consent", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(null); + const consent = yield* getEffectiveConsent(Option.none()); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1" }))), ); it.live("DO_NOT_TRACK=1 takes precedence over persisted granted consent", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(makeConfig("granted")); + const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ DO_NOT_TRACK: "1" }))), ); it.live("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over DO_NOT_TRACK=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(makeConfig("granted")); + const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1", DO_NOT_TRACK: "1" }))), ); it.live("returns config consent value when set", () => Effect.gen(function* () { - expect(yield* getEffectiveConsent(makeConfig("granted"))).toBe("granted"); - expect(yield* getEffectiveConsent(makeConfig("denied"))).toBe("denied"); + expect(yield* getEffectiveConsent(Option.some(makeConfig("granted")))).toBe("granted"); + expect(yield* getEffectiveConsent(Option.some(makeConfig("denied")))).toBe("denied"); }).pipe(Effect.provide(emptyEnv())), ); it.live("defaults to granted when no config (opt-out model)", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(null); + const consent = yield* getEffectiveConsent(Option.none()); expect(consent).toBe("granted"); }).pipe(Effect.provide(emptyEnv())), ); }); describe("readTelemetryConfig", () => { - it.live("returns null for malformed JSON instead of throwing", () => { + it.live("decodes a valid telemetry config", () => { + const dir = makeTempDir(); + const expected = makeConfig("denied"); + writeTelemetryFile(dir, JSON.stringify(expected)); + + return Effect.gen(function* () { + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual(Option.some(expected)); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); + + it.live("returns none for malformed JSON instead of throwing", () => { const dir = makeTempDir(); writeTelemetryFile(dir, ""); return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); - expect(config).toBeNull(); + expect(config).toEqual(Option.none()); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); + + it.live("returns none for structurally invalid telemetry config", () => { + const dir = makeTempDir(); + writeTelemetryFile(dir, JSON.stringify({ consent: "granted" })); + + return Effect.gen(function* () { + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual(Option.none()); }).pipe( Effect.provide(BunServices.layer), Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), diff --git a/apps/cli/src/shared/telemetry/identity.ts b/apps/cli/src/shared/telemetry/identity.ts index 6a74eae775..7edef25dd3 100644 --- a/apps/cli/src/shared/telemetry/identity.ts +++ b/apps/cli/src/shared/telemetry/identity.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { readTelemetryConfig, writeTelemetryConfig } from "./consent.ts"; import type { TelemetryConfig } from "./types.ts"; @@ -8,7 +8,7 @@ export const resolveIdentity = Effect.fnUntraced(function* (configDir: string) { const config = yield* readTelemetryConfig(configDir); const now = Date.now(); - if (!config) { + if (Option.isNone(config)) { const newConfig: TelemetryConfig = { consent: "granted", device_id: crypto.randomUUID(), @@ -24,17 +24,18 @@ export const resolveIdentity = Effect.fnUntraced(function* (configDir: string) { }; } - const isSessionExpired = now - config.session_last_active > SESSION_TIMEOUT_MS; - const sessionId = isSessionExpired ? crypto.randomUUID() : config.session_id; + const currentConfig = config.value; + const isSessionExpired = now - currentConfig.session_last_active > SESSION_TIMEOUT_MS; + const sessionId = isSessionExpired ? crypto.randomUUID() : currentConfig.session_id; yield* writeTelemetryConfig( - { ...config, session_id: sessionId, session_last_active: now }, + { ...currentConfig, session_id: sessionId, session_last_active: now }, configDir, ); return { - deviceId: config.device_id, + deviceId: currentConfig.device_id, sessionId, - distinctId: config.distinct_id, + distinctId: currentConfig.distinct_id, isFirstRun: false, }; }); @@ -43,7 +44,10 @@ export const saveDistinctId = Effect.fnUntraced(function* (configDir: string, di const identity = yield* resolveIdentity(configDir); const config = yield* readTelemetryConfig(configDir); const nextConfig: TelemetryConfig = { - consent: config?.consent ?? "granted", + consent: Option.match(config, { + onNone: () => "granted", + onSome: (value) => value.consent, + }), device_id: identity.deviceId, session_id: identity.sessionId, session_last_active: Date.now(), @@ -56,7 +60,10 @@ export const clearDistinctId = Effect.fnUntraced(function* (configDir: string) { const identity = yield* resolveIdentity(configDir); const config = yield* readTelemetryConfig(configDir); const nextConfig: TelemetryConfig = { - consent: config?.consent ?? "granted", + consent: Option.match(config, { + onNone: () => "granted", + onSome: (value) => value.consent, + }), device_id: identity.deviceId, session_id: identity.sessionId, session_last_active: Date.now(), diff --git a/apps/cli/src/shared/telemetry/runtime.layer.ts b/apps/cli/src/shared/telemetry/runtime.layer.ts index 0728b847ac..59f2690e6d 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.ts @@ -11,12 +11,12 @@ import { TelemetryRuntime } from "./runtime.service.ts"; const CI_ENV_VARS = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", "JENKINS_URL", "BUILDKITE"]; -function identityFromConfig(config: TelemetryConfig | null) { - if (config !== null) { +function identityFromConfig(config: Option.Option) { + if (Option.isSome(config)) { return { - deviceId: config.device_id, - sessionId: config.session_id, - distinctId: config.distinct_id, + deviceId: config.value.device_id, + sessionId: config.value.session_id, + distinctId: config.value.distinct_id, isFirstRun: false, } as const; } @@ -45,7 +45,7 @@ export const telemetryRuntimeLayer = Layer.effect( let identity; if (consent === "granted") { - if (config === null && isTty) { + if (Option.isNone(config) && isTty) { yield* Effect.sync(() => note( "Supabase collects anonymous usage data to improve the CLI.\nYou can opt out at any time:\n\n supabase telemetry disable\n\nLearn more: https://supabase.com/docs/guides/local-development/cli/getting-started#telemetry", diff --git a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts index 1e69610853..580cd9fb4b 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts @@ -95,4 +95,20 @@ describe("telemetryRuntimeLayer", () => { Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), ); }); + + it.live("silently ignores structurally invalid telemetry.json instead of crashing", () => { + const homeDir = makeTempDir(); + const configPath = path.join(homeDir, "telemetry.json"); + writeFileSync(configPath, JSON.stringify({ consent: "granted" })); + + return Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(existsSync(configPath)).toBe(true); + }).pipe( + Effect.provide(buildLayer({ homeDir })), + Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), + ); + }); }); diff --git a/apps/cli/src/shared/telemetry/types.ts b/apps/cli/src/shared/telemetry/types.ts index eeb12e0e02..44d1823536 100644 --- a/apps/cli/src/shared/telemetry/types.ts +++ b/apps/cli/src/shared/telemetry/types.ts @@ -1,9 +1,13 @@ -export type ConsentState = "granted" | "denied"; +import { Schema } from "effect"; -export type TelemetryConfig = { - consent: ConsentState; - device_id: string; - session_id: string; - session_last_active: number; - distinct_id?: string; -}; +const ConsentStateSchema = Schema.Literals(["granted", "denied"] as const); +export type ConsentState = Schema.Schema.Type; + +export const TelemetryConfigSchema = Schema.Struct({ + consent: ConsentStateSchema, + device_id: Schema.String, + session_id: Schema.String, + session_last_active: Schema.Number, + distinct_id: Schema.optionalKey(Schema.String), +}); +export type TelemetryConfig = Schema.Schema.Type;