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
81 changes: 76 additions & 5 deletions apps/cli-go/internal/telemetry/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions apps/cli-go/internal/telemetry/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
50 changes: 48 additions & 2 deletions apps/cli/src/shared/telemetry/consent.ts
Original file line number Diff line number Diff line change
@@ -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<typeof LegacyTelemetryConfigSchema>;

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;
Expand Down
60 changes: 60 additions & 0 deletions apps/cli/src/shared/telemetry/consent.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Expand Down
27 changes: 27 additions & 0 deletions apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))),
);
});
});
Loading