From 36ff7f0f99c55c33b53d3c03fa3ee5014b2a7bce Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 4 Jun 2026 09:00:55 +0200 Subject: [PATCH] fix(cli): persist legacy telemetry opt-out --- apps/cli-go/internal/telemetry/state.go | 81 +++++++++++++++++-- apps/cli-go/internal/telemetry/state_test.go | 48 +++++++++++ apps/cli/src/shared/telemetry/consent.ts | 50 +++++++++++- .../src/shared/telemetry/consent.unit.test.ts | 60 ++++++++++++++ .../telemetry/runtime.layer.unit.test.ts | 27 +++++++ 5 files changed, 259 insertions(+), 7 deletions(-) diff --git a/apps/cli-go/internal/telemetry/state.go b/apps/cli-go/internal/telemetry/state.go index 8d69996468..825ce5fc7c 100644 --- a/apps/cli-go/internal/telemetry/state.go +++ b/apps/cli-go/internal/telemetry/state.go @@ -32,6 +32,16 @@ type State struct { SchemaVersion int `json:"schema_version"` } +type rawState struct { + Enabled *bool `json:"enabled"` + Consent *string `json:"consent"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + SessionLastActive json.RawMessage `json:"session_last_active"` + DistinctID string `json:"distinct_id,omitempty"` + SchemaVersion int `json:"schema_version"` +} + func telemetryPath() (string, error) { if home := strings.TrimSpace(os.Getenv("SUPABASE_HOME")); home != "" { return filepath.Join(home, "telemetry.json"), nil @@ -43,6 +53,71 @@ func telemetryPath() (string, error) { return filepath.Join(home, ".supabase", "telemetry.json"), nil } +func parseConsent(raw rawState) (bool, bool, error) { + if raw.Consent != nil { + switch *raw.Consent { + case "granted": + return true, true, nil + case "denied": + return false, true, nil + default: + return false, false, errors.Errorf("%w: invalid consent", errMalformedState) + } + } + if raw.Enabled == nil { + return false, false, errors.Errorf("%w: missing enabled", errMalformedState) + } + return *raw.Enabled, false, nil +} + +func parseSessionLastActive(raw json.RawMessage, allowUnixMillis bool) (time.Time, error) { + var text string + if err := json.Unmarshal(raw, &text); err == nil { + parsed, err := time.Parse(time.RFC3339Nano, text) + if err != nil { + return time.Time{}, errors.Errorf("%w: invalid session_last_active", errMalformedState) + } + return parsed, nil + } + if allowUnixMillis { + var millis int64 + if err := json.Unmarshal(raw, &millis); err == nil { + return time.UnixMilli(millis).UTC(), nil + } + } + return time.Time{}, errors.Errorf("%w: invalid session_last_active", errMalformedState) +} + +func decodeState(contents []byte) (State, error) { + var raw rawState + if err := json.Unmarshal(contents, &raw); err != nil { + return State{}, errors.Errorf("%w: %v", errMalformedState, err) + } + enabled, allowUnixMillis, err := parseConsent(raw) + if err != nil { + return State{}, err + } + sessionLastActive, err := parseSessionLastActive(raw.SessionLastActive, allowUnixMillis) + if err != nil { + return State{}, err + } + if raw.DeviceID == "" || raw.SessionID == "" { + return State{}, errors.Errorf("%w: missing identity", errMalformedState) + } + schemaVersion := raw.SchemaVersion + if schemaVersion == 0 { + schemaVersion = SchemaVersion + } + return State{ + Enabled: enabled, + DeviceID: raw.DeviceID, + SessionID: raw.SessionID, + SessionLastActive: sessionLastActive, + DistinctID: raw.DistinctID, + SchemaVersion: schemaVersion, + }, nil +} + func LoadState(fsys afero.Fs) (State, error) { path, err := telemetryPath() if err != nil { @@ -52,11 +127,7 @@ func LoadState(fsys afero.Fs) (State, error) { if err != nil { return State{}, err } - var state State - if err := json.Unmarshal(contents, &state); err != nil { - return State{}, errors.Errorf("%w: %v", errMalformedState, err) - } - return state, nil + return decodeState(contents) } func SaveState(state State, fsys afero.Fs) error { diff --git a/apps/cli-go/internal/telemetry/state_test.go b/apps/cli-go/internal/telemetry/state_test.go index 9cd03dd967..7ca3a178c6 100644 --- a/apps/cli-go/internal/telemetry/state_test.go +++ b/apps/cli-go/internal/telemetry/state_test.go @@ -79,6 +79,54 @@ func TestLoadOrCreateState(t *testing.T) { assert.Equal(t, now, state.SessionLastActive) }) + t.Run("reads disabled TypeScript telemetry config", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + path, err := telemetryPath() + require.NoError(t, err) + require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) + require.NoError(t, afero.WriteFile( + fsys, + path, + []byte(`{"consent":"denied","device_id":"ts-device","session_id":"ts-session","session_last_active":1776770348993,"distinct_id":"user-123"}`), + 0644, + )) + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.False(t, created) + assert.False(t, state.Enabled) + assert.Equal(t, "ts-device", state.DeviceID) + assert.Equal(t, "ts-session", state.SessionID) + assert.Equal(t, "user-123", state.DistinctID) + assert.Equal(t, SchemaVersion, state.SchemaVersion) + }) + + t.Run("reads enabled TypeScript telemetry config", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + path, err := telemetryPath() + require.NoError(t, err) + require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) + require.NoError(t, afero.WriteFile( + fsys, + path, + []byte(`{"consent":"granted","device_id":"ts-device","session_id":"ts-session","session_last_active":1776770348993,"distinct_id":"user-123"}`), + 0644, + )) + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.False(t, created) + assert.True(t, state.Enabled) + assert.Equal(t, "ts-device", state.DeviceID) + assert.Equal(t, "ts-session", state.SessionID) + assert.Equal(t, "user-123", state.DistinctID) + assert.Equal(t, SchemaVersion, state.SchemaVersion) + }) + t.Run("recovers from corrupted state file", func(t *testing.T) { // Each entry simulates a real-world corruption shape we've observed. corruptions := map[string][]byte{ diff --git a/apps/cli/src/shared/telemetry/consent.ts b/apps/cli/src/shared/telemetry/consent.ts index 1f1bd235f1..275676d562 100644 --- a/apps/cli/src/shared/telemetry/consent.ts +++ b/apps/cli/src/shared/telemetry/consent.ts @@ -1,17 +1,63 @@ import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { CliConfig } from "../../next/config/cli-config.service.ts"; -import { TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; +import { type ConsentState, TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; export const getConfigDir = CliConfig.useSync((cliConfig) => cliConfig.supabaseHome); const TelemetryConfigFileSchema = Schema.fromJsonString(TelemetryConfigSchema); -const decodeTelemetryConfigFile = Schema.decodeUnknownEffect(TelemetryConfigFileSchema); +const LegacyTelemetryConfigSchema = Schema.Struct({ + enabled: Schema.Boolean, + device_id: Schema.String, + session_id: Schema.String, + session_last_active: Schema.String, + distinct_id: Schema.optionalKey(Schema.String), + schema_version: Schema.optionalKey(Schema.Number), +}); +type LegacyTelemetryConfig = Schema.Schema.Type; + +const decodeCurrentTelemetryConfigFile = Schema.decodeUnknownEffect(TelemetryConfigFileSchema); +const decodeLegacyTelemetryConfigFile = Schema.decodeUnknownEffect( + Schema.fromJsonString(LegacyTelemetryConfigSchema), +); const encodeTelemetryConfig = Schema.encodeUnknownSync(TelemetryConfigSchema); function encodePrettyJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } +function legacyConsent(enabled: boolean): ConsentState { + return enabled ? "granted" : "denied"; +} + +function legacyConfigToTelemetryConfig( + legacyConfig: LegacyTelemetryConfig, +): TelemetryConfig | undefined { + const sessionLastActive = Date.parse(legacyConfig.session_last_active); + if (!Number.isFinite(sessionLastActive)) return undefined; + return { + consent: legacyConsent(legacyConfig.enabled), + device_id: legacyConfig.device_id, + session_id: legacyConfig.session_id, + session_last_active: sessionLastActive, + ...(legacyConfig.distinct_id === undefined ? {} : { distinct_id: legacyConfig.distinct_id }), + }; +} + +const decodeTelemetryConfigFile = Effect.fnUntraced(function* (content: string) { + return yield* decodeCurrentTelemetryConfigFile(content).pipe( + Effect.catch(() => + Effect.gen(function* () { + const legacyConfig = yield* decodeLegacyTelemetryConfigFile(content); + const config = legacyConfigToTelemetryConfig(legacyConfig); + if (config === undefined) { + return yield* Effect.fail(new Error("invalid legacy telemetry state")); + } + return config; + }), + ), + ); +}); + export const readTelemetryConfig = Effect.fnUntraced( function* (configDir: string) { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/shared/telemetry/consent.unit.test.ts b/apps/cli/src/shared/telemetry/consent.unit.test.ts index adb4d63d30..e2257ee95e 100644 --- a/apps/cli/src/shared/telemetry/consent.unit.test.ts +++ b/apps/cli/src/shared/telemetry/consent.unit.test.ts @@ -118,6 +118,66 @@ describe("readTelemetryConfig", () => { ); }); + it.live("decodes a legacy disabled telemetry state as denied consent", () => { + const dir = makeTempDir(); + writeTelemetryFile( + dir, + JSON.stringify({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "denied", + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: Date.parse("2026-04-01T12:00:00Z"), + }), + ); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); + + it.live("decodes a legacy enabled telemetry state as granted consent", () => { + const dir = makeTempDir(); + writeTelemetryFile( + dir, + JSON.stringify({ + enabled: true, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + distinct_id: "user-123", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "granted", + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: Date.parse("2026-04-01T12:00:00Z"), + distinct_id: "user-123", + }), + ); + }).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, ""); 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 580cd9fb4b..fbb165b4fa 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts @@ -111,4 +111,31 @@ describe("telemetryRuntimeLayer", () => { Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), ); }); + + it.live("honors a legacy disabled telemetry state", () => { + const homeDir = makeTempDir(); + const configPath = path.join(homeDir, "telemetry.json"); + writeFileSync( + configPath, + JSON.stringify({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + + return Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("denied"); + expect(runtime.deviceId).toBe("legacy-device"); + expect(runtime.sessionId).toBe("legacy-session"); + expect(runtime.isFirstRun).toBe(false); + expect(existsSync(configPath)).toBe(true); + }).pipe( + Effect.provide(buildLayer({ homeDir, stdoutIsTty: true })), + Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), + ); + }); });