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
25 changes: 22 additions & 3 deletions apps/cli/src/legacy/commands/telemetry/disable/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ instead of `~/.supabase/telemetry.json`.

## API Routes

`disable` is fully local. No network calls are made.
None called directly. `cli_command_executed` may be sent to PostHog — see
Telemetry Events Fired below.

## Environment Variables

Expand All @@ -34,8 +35,26 @@ instead of `~/.supabase/telemetry.json`.

## Telemetry Events Fired

None. The command disables analytics capture so toggling telemetry does not emit
`cli_command_executed`, matching Go.
| Event | When |
| ---------------------- | ------------------------------------------------------------------------- |
| `cli_command_executed` | when telemetry was **already enabled** before this invocation (see below) |

Go parity (`apps/cli-go/cmd/root.go:131-138,171-181`): the event is gated on
the consent state read at process start, before this command's handler
rewrites `telemetry.json` — not on the value the command just wrote. Running
`disable` while telemetry is enabled fires the event one last time (using
the pre-toggle, still-enabled snapshot); running it while telemetry is
already disabled stays silent. See `telemetry/enable/SIDE_EFFECTS.md` for
the mirror-image case.

When the event fires, the process waits (bounded, up to ~5s) for a PostHog
flush attempt to complete or time out before exiting, since the analytics
client's shutdown is a finalizer around the whole command run. Previously
`disable`/`enable` never queued an event and returned immediately; on a
slow or fully offline network, this invocation can now take noticeably
longer than before, though the `Telemetry is disabled.` stdout line is
still written before that wait (it comes from the handler, which completes
before the surrounding instrumentation's post-run capture/flush step).

## Output

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ export const legacyTelemetryDisableCommand = Command.make("disable", config).pip
Command.withDescription("Disable CLI telemetry."),
Command.withShortDescription("Disable telemetry"),
Command.withHandler((flags) =>
// Go parity (`cmd/root.go:131-138,171-181`): `cli_command_executed` fires
// gated on the CONSENT SNAPSHOT TAKEN BEFORE this command's handler runs
// (Go's PersistentPreRunE reads the on-disk state before `disable`'s RunE
// mutates it), not on the just-written `false`. `legacyAnalyticsLayer`
// reproduces that naturally: it reads `TelemetryRuntime.consent` once, at
// layer-construction time, before the handler below ever executes, so
// leaving analytics enabled here fires the event exactly when telemetry
// was enabled prior to this invocation, and stays silent when it was
// already disabled — see `enable.command.ts` for the mirror-image case.
legacyTelemetryDisable(flags).pipe(
withLegacyCommandInstrumentation({ analytics: false, flags }),
withLegacyCommandInstrumentation({ flags }),
withJsonErrorHandling,
),
),
Expand Down
26 changes: 23 additions & 3 deletions apps/cli/src/legacy/commands/telemetry/enable/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ instead of `~/.supabase/telemetry.json`.

## API Routes

`enable` is fully local. No network calls are made.
None called directly. `cli_command_executed` may be sent to PostHog — see
Telemetry Events Fired below.

## Environment Variables

Expand All @@ -34,8 +35,27 @@ instead of `~/.supabase/telemetry.json`.

## Telemetry Events Fired

None. The command disables analytics capture so toggling telemetry does not emit
`cli_command_executed`, matching Go.
| Event | When |
| ---------------------- | ------------------------------------------------------------------------- |
| `cli_command_executed` | when telemetry was **already enabled** before this invocation (see below) |

Go parity (`apps/cli-go/cmd/root.go:131-138,171-181`): the event is gated on
the consent state read at process start, before this command's handler
rewrites `telemetry.json` — not on the value the command just wrote. In the
common case (enabling from a disabled state) the pre-toggle snapshot is
`false`, so nothing fires; running `enable` while telemetry is already
enabled fires the event, matching Go's uniform, state-based (not
command-based) gate. See `telemetry/disable/SIDE_EFFECTS.md` for the
mirror-image case.

When the event fires, the process waits (bounded, up to ~5s) for a PostHog
flush attempt to complete or time out before exiting, since the analytics
client's shutdown is a finalizer around the whole command run. Previously
`disable`/`enable` never queued an event and returned immediately; on a
slow or fully offline network, this invocation can now take noticeably
longer than before, though the `Telemetry is enabled.` stdout line is
still written before that wait (it comes from the handler, which completes
before the surrounding instrumentation's post-run capture/flush step).

## Output

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ export const legacyTelemetryEnableCommand = Command.make("enable", config).pipe(
Command.withDescription("Enable CLI telemetry."),
Command.withShortDescription("Enable telemetry"),
Command.withHandler((flags) =>
// Go parity (`cmd/root.go:131-138,171-181`): `cli_command_executed` fires
// gated on the pre-toggle consent snapshot, same as `disable` — see that
// command's comment. In the common case (enabling from a disabled state)
// the snapshot is `false`, so the event stays silent; running `enable`
// when telemetry is ALREADY enabled fires it, matching Go's uniform,
// state-based (not command-based) gate.
legacyTelemetryEnable(flags).pipe(
withLegacyCommandInstrumentation({ analytics: false, flags }),
withLegacyCommandInstrumentation({ flags }),
withJsonErrorHandling,
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,18 @@ import path from "node:path";
import { Effect, Layer } from "effect";
import { Command } from "effect/unstable/cli";

import { mockAnalytics, mockOutput, processEnvLayer } from "../../../../tests/helpers/mocks.ts";
import {
mockAnalytics,
mockOutput,
mockProjectContext,
mockRuntimeInfo,
mockTty,
processEnvLayer,
} from "../../../../tests/helpers/mocks.ts";
import { cliConfigLayer } from "../../../next/config/cli-config.layer.ts";
import { processControlLayer } from "../../../shared/runtime/process-control.layer.ts";
import { EventCommandExecuted } from "../../../shared/telemetry/event-catalog.ts";
import { legacyAnalyticsLayer } from "../../telemetry/legacy-analytics.layer.ts";
import { legacyTelemetryCommand } from "./telemetry.command.ts";

function makeTempDir(): string {
Expand All @@ -32,6 +42,42 @@ function setup(dir: string) {
processControlLayer,
processEnvLayer({ SUPABASE_HOME: dir }),
);
return { out, analytics, layer };
}

// Wires the REAL `legacyAnalyticsLayer` (consent-gated, backed by
// `telemetryRuntimeLayer` reading `dir`'s telemetry.json) instead of
// `mockAnalytics()` — the un-mocked boundary `Analytics.capture` calls
// actually pass through. No PostHog key is set in the test env, so
// `legacyAnalyticsLayer` resolves to its no-op branch regardless of consent
// (real-network PostHog delivery has no test double anywhere in this repo);
// this proves the command runs the real consent-gated layer end-to-end
// without crashing, not the exact PostHog call count. The snapshot-timing
// mechanism itself (pre-toggle consent surviving the handler's own disk
// write) is proven directly in `runtime.layer.unit.test.ts`.
function setupWithRealAnalytics(dir: string) {
const out = mockOutput();
const runtimeInfoLayer = mockRuntimeInfo({ homeDir: dir });
const ttyLayer = mockTty();
const envLayer = processEnvLayer({ SUPABASE_HOME: dir });
const projectContextLayer = mockProjectContext();
const configLayer = cliConfigLayer.pipe(
Layer.provide(runtimeInfoLayer),
Layer.provide(projectContextLayer),
);
const analyticsLayer = legacyAnalyticsLayer.pipe(
Layer.provide(configLayer),
Layer.provide(runtimeInfoLayer),
Layer.provide(ttyLayer),
Layer.provide(BunServices.layer),
);
const layer = Layer.mergeAll(
out.layer,
analyticsLayer,
BunServices.layer,
processControlLayer,
envLayer,
);
return { out, layer };
}

Expand Down Expand Up @@ -135,6 +181,91 @@ describe("legacy telemetry integration", () => {
) as Effect.Effect<void>;
});

// Go parity (`cmd/root.go:131-138,171-181`): `cli_command_executed` is gated on
// the consent SNAPSHOT taken before the handler runs, not the value the handler
// just wrote. These two assert the narrower wiring fix using `mockAnalytics()`
// (which unconditionally records every capture, bypassing consent entirely):
// `disable`/`enable` no longer force-suppress analytics via `analytics: false`,
// so the shared instrumentation wrapper actually reaches `Analytics.capture`.
// The snapshot-timing mechanism itself — that the pre-toggle value survives the
// handler's own on-disk write — is proven directly against `telemetryRuntimeLayer`
// in `shared/telemetry/runtime.layer.unit.test.ts`. The two tests further below
// run the same commands through the REAL, consent-gated `legacyAnalyticsLayer`
// (not this mock) to prove the production wiring doesn't crash end-to-end.
it.live("disable no longer force-suppresses cli_command_executed", () => {
const dir = makeTempDir();
const { analytics, layer } = setup(dir);

return Effect.gen(function* () {
yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "disable"]);
expect(analytics.captured.map((event) => event.event)).toContain(EventCommandExecuted);
}).pipe(
Effect.provide(layer),
Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))),
) as Effect.Effect<void>;
});

it.live("enable no longer force-suppresses cli_command_executed", () => {
const dir = makeTempDir();
const { analytics, layer } = setup(dir);

return Effect.gen(function* () {
yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "enable"]);
expect(analytics.captured.map((event) => event.event)).toContain(EventCommandExecuted);
}).pipe(
Effect.provide(layer),
Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))),
) as Effect.Effect<void>;
});

it.live("disable runs cleanly through the real consent-gated analytics layer", () => {
const dir = makeTempDir();
writeFileSync(
telemetryPath(dir),
JSON.stringify({
enabled: true,
device_id: "device-123",
session_id: "session-123",
session_last_active: "2026-01-01T00:00:00.000Z",
schema_version: 1,
}),
);
const { out, layer } = setupWithRealAnalytics(dir);

return Effect.gen(function* () {
yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "disable"]);
expect(out.stdoutText).toBe("Telemetry is disabled.\n");
expect(readTelemetryConfig(dir).enabled).toBe(false);
}).pipe(
Effect.provide(layer),
Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))),
) as Effect.Effect<void>;
});

it.live("enable runs cleanly through the real consent-gated analytics layer", () => {
const dir = makeTempDir();
writeFileSync(
telemetryPath(dir),
JSON.stringify({
enabled: false,
device_id: "device-123",
session_id: "session-123",
session_last_active: "2026-01-01T00:00:00.000Z",
schema_version: 1,
}),
);
const { out, layer } = setupWithRealAnalytics(dir);

return Effect.gen(function* () {
yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "enable"]);
expect(out.stdoutText).toBe("Telemetry is enabled.\n");
expect(readTelemetryConfig(dir).enabled).toBe(true);
}).pipe(
Effect.provide(layer),
Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))),
) as Effect.Effect<void>;
});

it.live(
"status treats malformed typed fields as a corrupted file and regenerates identity",
() => {
Expand Down
46 changes: 46 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 @@ -138,4 +138,50 @@ describe("telemetryRuntimeLayer", () => {
Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))),
);
});

// CLI-1868 (telemetry enable/disable firing cli_command_executed on pre-toggle
// consent) depends on this exact property: `consent` is read from disk once
// at layer-construction time and does not reflect a later on-disk write —
// mirroring Go's PersistentPreRunE snapshot, which a command's own RunE
// (e.g. `telemetry disable`'s SetEnabled) cannot retroactively change.
it.live("captures consent once; a later on-disk write does not change it", () => {
const homeDir = makeTempDir();
const configPath = path.join(homeDir, "telemetry.json");
writeFileSync(
configPath,
JSON.stringify({
enabled: true,
device_id: "device-123",
session_id: "session-123",
session_last_active: "2026-04-01T12:00:00Z",
schema_version: 1,
}),
);

return Effect.gen(function* () {
const runtime = yield* TelemetryRuntime;
expect(runtime.consent).toBe("granted");

// Simulates `disable`'s handler rewriting the file mid-command, after
// this layer already resolved `consent` — the already-built runtime
// must keep reporting the pre-toggle value.
yield* Effect.sync(() =>
writeFileSync(
configPath,
JSON.stringify({
enabled: false,
device_id: "device-123",
session_id: "session-123",
session_last_active: "2026-04-01T12:00:00Z",
schema_version: 1,
}),
),
);

expect(runtime.consent).toBe("granted");
}).pipe(
Effect.provide(buildLayer({ homeDir })),
Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))),
);
});
});
Loading