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
7 changes: 5 additions & 2 deletions apps/cli/src/next/commands/telemetry/telemetry.command.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
}),
});
});

Expand Down
40 changes: 24 additions & 16 deletions apps/cli/src/shared/telemetry/consent.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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<TelemetryConfig>();
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<TelemetryConfig>()),
);

export const writeTelemetryConfig = Effect.fnUntraced(function* (
Expand All @@ -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<TelemetryConfig>,
) {
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,
});
});
49 changes: 38 additions & 11 deletions apps/cli/src/shared/telemetry/consent.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }))),
Expand Down
25 changes: 16 additions & 9 deletions apps/cli/src/shared/telemetry/identity.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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(),
Expand All @@ -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,
};
});
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down
12 changes: 6 additions & 6 deletions apps/cli/src/shared/telemetry/runtime.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TelemetryConfig>) {
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;
}
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 16 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 @@ -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 }))),
);
});
});
20 changes: 12 additions & 8 deletions apps/cli/src/shared/telemetry/types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ConsentStateSchema>;

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<typeof TelemetryConfigSchema>;
Loading