diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index ceb29b149e3d6..9ed1c36f0c577 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -119,6 +119,27 @@ const writeObservabilitySettings = Effect.fn(function* ( }); describe("DesktopObservability", () => { + it.effect("reads telemetry as on without a settings file and off when it is unreadable", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-telemetry-setting-test-", + }); + const layerEnvironment = layerEnvironmentFor(baseDir); + const read = DesktopObservability.readPersistedObservabilitySettings.pipe( + Effect.provide(layerEnvironment), + ); + assert.isTrue((yield* read).telemetryEnabled); + + const { serverSettingsPath } = yield* DesktopEnvironment.DesktopEnvironment.pipe( + Effect.provide(layerEnvironment), + ); + // A directory where the file should be fails to read without being missing. + yield* fileSystem.makeDirectory(serverSettingsPath, { recursive: true }); + assert.isFalse((yield* read).telemetryEnabled); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("buffers backend child output and persists it only when a failure is reported", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 16192fff46de8..5f0e9f3430d4f 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -11,6 +11,7 @@ import * as SharedObservability from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { releaseChannelOf, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import { + NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS, parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, } from "@t3tools/shared/serverSettings"; @@ -330,23 +331,24 @@ export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")( } satisfies RotatingLogFileWriter; }); -const noPersistedObservabilitySettings: PersistedServerObservabilitySettings = { - otlpTracesUrl: undefined, - otlpMetricsUrl: undefined, - otlpLogsUrl: undefined, -}; - -const readPersistedObservabilitySettings: Effect.Effect< +export const readPersistedObservabilitySettings: Effect.Effect< PersistedServerObservabilitySettings, never, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); - return Option.isNone(raw) - ? noPersistedObservabilitySettings - : parsePersistedServerObservabilitySettings(raw.value); + return yield* fileSystem.readFileString(environment.serverSettingsPath).pipe( + Effect.map(parsePersistedServerObservabilitySettings), + // A missing file saved nothing; an unreadable one may hold a telemetry opt-out. + Effect.catch((cause) => + Effect.succeed( + cause.reason._tag === "NotFound" + ? NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS + : { ...NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS, telemetryEnabled: false }, + ), + ), + ); }); const resolveOtlpEndpoints = Effect.gen(function* () { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 9f2f68be61520..24af448f01a80 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; @@ -97,7 +98,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(takeLegacyLocalStorage); yield* ipc.handle(completeLegacyLocalStorage); yield* ipc.handleSync(getAppBranding); - yield* installRelayTelemetryEnabled(); + // The renderer reads this once while loading; a changed setting applies after a restart. + const { telemetryEnabled } = yield* DesktopObservability.readPersistedObservabilitySettings; + yield* installRelayTelemetryEnabled(telemetryEnabled); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getPreferredSystemLanguages); yield* ipc.handleSync(getPrimaryPasteEnabled); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 376f85d2baf9d..434190b835e0d 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -190,6 +190,7 @@ describe("installRelayTelemetryEnabled", () => { const relayTelemetryEnabled = ( env: NodeJS.ProcessEnv, policy?: Parameters[0], + telemetrySetting = true, ) => Effect.gen(function* () { const listeners = new Map(); @@ -199,7 +200,7 @@ describe("installRelayTelemetryEnabled", () => { removeAllListeners: (channel) => void listeners.delete(channel), on: (channel, listener) => void listeners.set(channel, listener), }; - yield* installRelayTelemetryEnabled().pipe( + yield* installRelayTelemetryEnabled(telemetrySetting).pipe( Effect.provideService(HostProcess.Environment, env), Effect.provide( Layer.mergeAll(DesktopIpc.layerTest(ipcMain), DesktopManagedPolicy.layerTest(policy)), @@ -235,6 +236,15 @@ describe("installRelayTelemetryEnabled", () => { ); }), ); + + it.effect("follows the server setting unless the host variable is set", () => + Effect.gen(function* () { + assert.isFalse(yield* relayTelemetryEnabled({}, undefined, false)); + assert.isTrue( + yield* relayTelemetryEnabled({ T3CODE_TELEMETRY_ENABLED: "true" }, undefined, false), + ); + }), + ); }); describe("readMedia", () => { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 53a1138d71a99..85348745ff1fe 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -77,13 +77,14 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ /** * Relay traces leave the machine, so a policy with `telemetry: false`, or one that cannot be read, - * stops them. Read once here: a sync IPC handler cannot wait on the filesystem. + * stops them. `telemetrySetting` is the local server's `telemetryEnabled` when the app started. + * Read once here: a sync IPC handler cannot wait on the filesystem. */ export const installRelayTelemetryEnabled = Effect.fn("desktop.ipc.window.installRelayTelemetry")( - function* () { + function* (telemetrySetting: boolean) { const ipc = yield* DesktopIpc.DesktopIpc; const enabled = - isRelayTracingEnabled(yield* HostProcess.Environment) && + isRelayTracingEnabled(yield* HostProcess.Environment, telemetrySetting) && (yield* (yield* DesktopManagedPolicy.DesktopManagedPolicy).read.pipe( Effect.map((found) => found?.document.limits?.telemetry !== false), Effect.orElseSucceed(() => false), diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index 995b207e7c781..aeacabf672378 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -36,6 +36,7 @@ import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; import { sshHelperCommand } from "./cli/sshHelper.ts"; import { serviceCommand } from "./cli/service.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; +import { telemetryCommand } from "./cli/telemetry.ts"; import { themeCommand } from "./cli/theme.ts"; import { traceCommand } from "./cli/trace.ts"; import { triageCommand } from "./cli/triage.ts"; @@ -109,6 +110,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => servicePreflightCommand, themeCommand, + telemetryCommand, traceCommand, doctorCommand, policyCommand, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 5e09dda2c164f..c4ee1012f9cc3 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -99,6 +99,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otelEnvironment: OtelEnvironment.none, + telemetryEnabledInSettings: true, devAllowedOrigins: [], } as const; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index a7b9584d1a7e6..70d6a8e1bc0a2 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -5,7 +5,10 @@ import { type SignalExport, } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; -import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { + NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS, + parsePersistedServerObservabilitySettings, +} from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; import * as Duration from "effect/Duration"; @@ -120,6 +123,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + telemetryEnabled: Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), @@ -253,7 +260,7 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st const fs = yield* FileSystem.FileSystem; const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); if (!exists) { - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; + return NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS; } const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); @@ -498,6 +505,10 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct ...(logs !== undefined ? { otlpLogsSource: logs.source } : {}), ...(logs?.variable !== undefined ? { otlpLogsVariable: logs.variable } : {}), otelEnvironment: otel, + ...(env.telemetryEnabled === undefined + ? {} + : { telemetryEnabledByEnvironment: env.telemetryEnabled }), + telemetryEnabledInSettings: persistedObservabilitySettings.telemetryEnabled, mode, port, cwd, diff --git a/apps/server/src/cli/settingsFile.ts b/apps/server/src/cli/settingsFile.ts new file mode 100644 index 0000000000000..1a37dffc467b9 --- /dev/null +++ b/apps/server/src/cli/settingsFile.ts @@ -0,0 +1,138 @@ +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "../config.ts"; +import { resolveBaseDir } from "../os-jank.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; + +const SparseSettings = Schema.Record(Schema.String, Schema.Unknown); +const decodeSettingsJson = Schema.decodeUnknownEffect(fromLenientJson(SparseSettings)); +const encodeSettingsJson = Schema.encodeEffect(fromJsonStringPretty(SparseSettings)); + +/** The environment's settings.json as written: only the keys that differ from defaults. */ +export type SparseSettings = typeof SparseSettings.Type; + +export class SettingsUnreadableError extends Schema.TaggedError()( + "SettingsUnreadableError", + { settingsPath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not read ${this.settingsPath}. Fix its permissions, then run this again.`; + } +} + +export class SettingsMalformedError extends Schema.TaggedError()( + "SettingsMalformedError", + { settingsPath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `${this.settingsPath} is not a JSON object. Fix or remove it, then run this again.`; + } +} + +export class SettingsBusyError extends Schema.TaggedError()( + "SettingsBusyError", + { settingsPath: Schema.String, attempts: Schema.Number }, +) { + override get message(): string { + return `${this.settingsPath} kept changing while writing (gave up after ${this.attempts} attempts). Try again.`; + } +} + +export class SettingsWriteError extends Schema.TaggedError()( + "SettingsWriteError", + { settingsPath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not write ${this.settingsPath}.`; + } +} + +const envT3Home = Config.String("T3CODE_HOME").pipe(Config.option); +const DEV_STATE_PLACEHOLDER_URL = new URL("http://localhost"); + +const hasLiveServer = (statePath: string) => + readPersistedServerRuntimeState(statePath).pipe( + Effect.map((state) => Option.isSome(state) && isProcessAlive(state.value.pid)), + Effect.orElseSucceed(() => false), + ); + +/** + * The paths of the environment a settings command targets: `--base-dir`, else `T3CODE_HOME`. + * With neither, a dev server's `dev` state wins unless a `userdata` server is running too, + * the same order `t3 pair` checks. + */ +export const resolveCliServerPaths = Effect.fn(function* (explicitBaseDir: Option.Option) { + const envHome = Option.filter(yield* envT3Home, (value) => value.trim().length > 0); + const configuredBaseDir = Option.orElse(explicitBaseDir, () => envHome); + const baseDir = yield* resolveBaseDir(Option.getOrUndefined(configuredBaseDir)); + const userdataPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, { + baseDirIsExplicit: Option.isSome(configuredBaseDir), + }); + if ( + Option.isSome(configuredBaseDir) || + (yield* hasLiveServer(userdataPaths.serverRuntimeStatePath)) + ) { + return userdataPaths; + } + const devPaths = yield* ServerConfig.deriveServerPaths(baseDir, DEV_STATE_PLACEHOLDER_URL); + return (yield* hasLiveServer(devPaths.serverRuntimeStatePath)) ? devPaths : userdataPaths; +}); + +const NO_SETTINGS: SparseSettings = {}; + +/** Propagate read errors to avoid overwriting unread settings with a sparse file. */ +export const readSettingsObject = Effect.fn(function* (settingsPath: string) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs + .exists(settingsPath) + .pipe(Effect.mapError((cause) => new SettingsUnreadableError({ settingsPath, cause }))); + if (!exists) return { exists, raw: "", settings: NO_SETTINGS }; + + const raw = yield* fs + .readFileString(settingsPath) + .pipe(Effect.mapError((cause) => new SettingsUnreadableError({ settingsPath, cause }))); + if (raw.trim().length === 0) return { exists, raw, settings: NO_SETTINGS }; + + const settings = yield* decodeSettingsJson(raw).pipe( + Effect.mapError((cause) => new SettingsMalformedError({ settingsPath, cause })), + ); + return { exists, raw, settings }; +}); + +/** The server's in-process semaphore cannot serialize writes from this process. */ +const CONCURRENT_WRITE_ATTEMPTS = 5; + +/** + * Rewrites settings.json with `update`, retrying when another writer changed it meanwhile. + * A running server picks the change up from its file watcher. + */ +export const updateSettingsObject = Effect.fn(function* ( + settingsPath: string, + update: (settings: SparseSettings) => Effect.Effect, +) { + const fs = yield* FileSystem.FileSystem; + + for (let attempt = 1; ; attempt++) { + const { raw, settings } = yield* readSettingsObject(settingsPath); + const contents = yield* encodeSettingsJson(yield* update(settings)); + const current = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); + if (current !== raw) { + if (attempt >= CONCURRENT_WRITE_ATTEMPTS) { + return yield* new SettingsBusyError({ settingsPath, attempts: CONCURRENT_WRITE_ATTEMPTS }); + } + continue; + } + + yield* writeFileStringAtomically({ + filePath: settingsPath, + contents: `${contents}\n`, + }).pipe(Effect.mapError((cause) => new SettingsWriteError({ settingsPath, cause }))); + return; + } +}); diff --git a/apps/server/src/cli/telemetry.test.ts b/apps/server/src/cli/telemetry.test.ts new file mode 100644 index 0000000000000..93c7f6a0159de --- /dev/null +++ b/apps/server/src/cli/telemetry.test.ts @@ -0,0 +1,97 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/cli"; + +import { cli } from "../binCli.ts"; + +/** Runs one command and returns what it printed. */ +const runCli = (args: ReadonlyArray, env: Record = {}) => + Command.runWith(cli, { version: "0.0.0" })(args).pipe( + Effect.andThen(TestConsole.logLines), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + NetService.layer, + Layer.fresh(TestConsole.layer), + ConfigProvider.layer(ConfigProvider.fromUnknown(env)), + ), + ), + ); + +const makeBaseDir = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-telemetry-cli-" }); + return { baseDir, settingsPath: path.join(baseDir, "userdata", "settings.json") }; +}); + +const readSettings = Effect.fn(function* (settingsPath: string) { + const fs = yield* FileSystem.FileSystem; + return JSON.parse(yield* fs.readFileString(settingsPath)) as Record; +}); + +it.layer(NodeServices.layer)("t3 telemetry", (it) => { + it.effect("turns the setting off and back on, keeping other settings", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir, settingsPath } = yield* makeBaseDir; + yield* fs.makeDirectory(path.dirname(settingsPath), { recursive: true }); + yield* fs.writeFileString( + settingsPath, + `${JSON.stringify({ somethingFromANewerBuild: { nested: true } })}\n`, + ); + + yield* runCli(["telemetry", "off", "--base-dir", baseDir]); + assert.deepEqual(yield* readSettings(settingsPath), { + somethingFromANewerBuild: { nested: true }, + telemetryEnabled: false, + }); + const off = yield* runCli(["telemetry", "status", "--base-dir", baseDir]); + assert.isTrue(off.some((line) => String(line).startsWith("Telemetry setting: off"))); + + yield* runCli(["telemetry", "on", "--base-dir", baseDir]); + assert.deepEqual(yield* readSettings(settingsPath), { + somethingFromANewerBuild: { nested: true }, + }); + const on = yield* runCli(["telemetry", "status", "--base-dir", baseDir]); + assert.isTrue(on.some((line) => String(line).startsWith("Telemetry setting: on"))); + }).pipe(Effect.scoped), + ); + + it.effect("reports a blank settings file as off, like the server, and a missing one as on", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir, settingsPath } = yield* makeBaseDir; + const missing = yield* runCli(["telemetry", "status", "--base-dir", baseDir]); + assert.isTrue(missing.some((line) => String(line).startsWith("Telemetry setting: on"))); + + yield* fs.makeDirectory(path.dirname(settingsPath), { recursive: true }); + yield* fs.writeFileString(settingsPath, " \n"); + const blank = yield* runCli(["telemetry", "status", "--base-dir", baseDir]); + assert.isTrue(blank.some((line) => String(line).startsWith("Telemetry setting: off"))); + }).pipe(Effect.scoped), + ); + + it.effect("says when the variable in this shell overrides the setting", () => + Effect.gen(function* () { + const { baseDir } = yield* makeBaseDir; + const lines = yield* runCli(["telemetry", "status", "--base-dir", baseDir], { + T3CODE_TELEMETRY_ENABLED: "false", + }); + assert.isTrue( + lines.some((line) => String(line).startsWith("T3CODE_TELEMETRY_ENABLED=false is set")), + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/cli/telemetry.ts b/apps/server/src/cli/telemetry.ts new file mode 100644 index 0000000000000..9b066c19618dc --- /dev/null +++ b/apps/server/src/cli/telemetry.ts @@ -0,0 +1,74 @@ +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { Command } from "effect/cli"; + +import { baseDirFlag } from "./config.ts"; +import { readSettingsObject, resolveCliServerPaths, updateSettingsObject } from "./settingsFile.ts"; + +const envTelemetryEnabled = Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.option); + +/** The variable overrides the setting for a server that sees it, so say when this shell sets it. */ +const noteEnvironmentOverride = Effect.gen(function* () { + const fromEnvironment = yield* envTelemetryEnabled; + if (Option.isSome(fromEnvironment)) { + yield* Console.log( + `T3CODE_TELEMETRY_ENABLED=${fromEnvironment.value} is set in this shell. A server started with it ignores this setting.\n`, + ); + } +}); + +const writeTelemetrySetting = (settingsPath: string, enabled: boolean) => + updateSettingsObject(settingsPath, (settings) => + Effect.succeed( + enabled + ? // On is the default, and settings.json keeps only what differs from defaults. + Object.fromEntries(Object.entries(settings).filter(([key]) => key !== "telemetryEnabled")) + : { ...settings, telemetryEnabled: false }, + ), + ); + +const telemetrySetCommand = (enabled: boolean) => + Command.make(enabled ? "on" : "off", { baseDir: baseDirFlag }).pipe( + Command.withDescription( + enabled + ? "Send product usage events and T3 Connect connection traces." + : "Stop sending product usage events and T3 Connect connection traces.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const paths = yield* resolveCliServerPaths(flags.baseDir); + yield* writeTelemetrySetting(paths.settingsPath, enabled); + yield* Console.log( + `Telemetry turned ${enabled ? "on" : "off"} in ${paths.settingsPath}. A running server applies it to usage events now and to connection traces after it restarts.\n`, + ); + yield* noteEnvironmentOverride; + }), + ), + ); + +const telemetryStatusCommand = Command.make("status", { baseDir: baseDirFlag }).pipe( + Command.withDescription("Show whether the environment's telemetry setting is on."), + Command.withHandler((flags) => + Effect.gen(function* () { + const paths = yield* resolveCliServerPaths(flags.baseDir); + const { exists, raw, settings } = yield* readSettingsObject(paths.settingsPath); + // The server cannot parse a blank file, so it treats telemetry as off. + const off = settings.telemetryEnabled === false || (exists && raw.trim().length === 0); + yield* Console.log(`Telemetry setting: ${off ? "off" : "on"} (${paths.settingsPath}).\n`); + yield* noteEnvironmentOverride; + }), + ), +); + +export const telemetryCommand = Command.make("telemetry").pipe( + Command.withDescription( + "Turn product usage events and T3 Connect connection traces on or off for this environment.", + ), + Command.withSubcommands([ + telemetrySetCommand(true), + telemetrySetCommand(false), + telemetryStatusCommand, + ]), +); diff --git a/apps/server/src/cli/theme.ts b/apps/server/src/cli/theme.ts index 6e83272c5d34f..7034fdd4a834b 100644 --- a/apps/server/src/cli/theme.ts +++ b/apps/server/src/cli/theme.ts @@ -8,9 +8,7 @@ import { EnvironmentThemeId, environmentThemeFileHasColors, } from "@t3tools/contracts"; -import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; -import * as Config from "effect/Config"; import * as Console from "effect/Console"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -20,61 +18,20 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/cli"; -import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import * as ServerConfig from "../config.ts"; import { MAX_THEME_FILE_BYTES, readPublishedThemes, readThemeFileGuarded, } from "../environmentTheme.ts"; -import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; -import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { expandHomePath } from "../os-jank.ts"; import { baseDirFlag } from "./config.ts"; +import { readSettingsObject, resolveCliServerPaths, updateSettingsObject } from "./settingsFile.ts"; -const SparseSettings = Schema.Record(Schema.String, Schema.Unknown); -const decodeSettingsJson = Schema.decodeUnknownEffect(fromLenientJson(SparseSettings)); -const encodeSettingsJson = Schema.encodeEffect(fromJsonStringPretty(SparseSettings)); const decodeThemeFileJsonExit = Schema.decodeUnknownExit( Schema.fromJsonString(EnvironmentThemeFile), ); const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); -export class ThemeSettingsUnreadableError extends Schema.TaggedError()( - "ThemeSettingsUnreadableError", - { settingsPath: Schema.String, cause: Schema.Defect() }, -) { - override get message(): string { - return `Could not read ${this.settingsPath}. Fix its permissions, then run this again.`; - } -} - -export class ThemeSettingsMalformedError extends Schema.TaggedError()( - "ThemeSettingsMalformedError", - { settingsPath: Schema.String, cause: Schema.Defect() }, -) { - override get message(): string { - return `${this.settingsPath} is not a JSON object. Fix or remove it, then run this again.`; - } -} - -export class ThemeSettingsBusyError extends Schema.TaggedError()( - "ThemeSettingsBusyError", - { settingsPath: Schema.String, attempts: Schema.Number }, -) { - override get message(): string { - return `${this.settingsPath} kept changing while writing (gave up after ${this.attempts} attempts). Try again.`; - } -} - -export class ThemeSettingsWriteError extends Schema.TaggedError()( - "ThemeSettingsWriteError", - { settingsPath: Schema.String, cause: Schema.Defect() }, -) { - override get message(): string { - return `Could not write ${this.settingsPath}.`; - } -} - export class ThemeFileUnreadableError extends Schema.TaggedError()( "ThemeFileUnreadableError", { filePath: Schema.String, cause: Schema.optional(Schema.Defect()) }, @@ -159,104 +116,27 @@ export class ThemeTargetMissingError extends Schema.TaggedError - readPersistedServerRuntimeState(statePath).pipe( - Effect.map((state) => Option.isSome(state) && isProcessAlive(state.value.pid)), - Effect.orElseSucceed(() => false), - ); - const resolveThemePaths = Effect.fn(function* (explicitBaseDir: Option.Option) { - const envHome = Option.filter(yield* envT3Home, (value) => value.trim().length > 0); - const configuredBaseDir = Option.orElse(explicitBaseDir, () => envHome); - const baseDir = yield* resolveBaseDir(Option.getOrUndefined(configuredBaseDir)); - const userdataPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, { - baseDirIsExplicit: Option.isSome(configuredBaseDir), - }); - // A dev server with an implicit home keeps its state in `dev`; follow it unless a - // `userdata` server is running too, the same order `t3 pair` checks. - let derivedPaths = userdataPaths; - if ( - Option.isNone(configuredBaseDir) && - !(yield* hasLiveServer(userdataPaths.serverRuntimeStatePath)) - ) { - const devPaths = yield* ServerConfig.deriveServerPaths(baseDir, DEV_STATE_PLACEHOLDER_URL); - if (yield* hasLiveServer(devPaths.serverRuntimeStatePath)) derivedPaths = devPaths; - } + const derivedPaths = yield* resolveCliServerPaths(explicitBaseDir); return { settingsPath: derivedPaths.settingsPath, themesDir: derivedPaths.environmentThemesDir, }; }); -/** Propagate read errors to avoid overwriting unread settings with a sparse file. */ -const readSettingsObject = Effect.fn(function* (settingsPath: string) { - const fs = yield* FileSystem.FileSystem; - const exists = yield* fs - .exists(settingsPath) - .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause }))); - if (!exists) return { raw: "", settings: {} }; - - const raw = yield* fs - .readFileString(settingsPath) - .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause }))); - if (raw.trim().length === 0) return { raw, settings: {} }; - - const settings = yield* decodeSettingsJson(raw).pipe( - Effect.mapError((cause) => new ThemeSettingsMalformedError({ settingsPath, cause })), - ); - return { raw, settings }; -}); - -/** The server's in-process semaphore cannot serialize writes from this process. */ -const CONCURRENT_WRITE_ATTEMPTS = 5; - -const writeDefaultTheme = Effect.fn(function* (input: { - readonly settingsPath: string; - readonly themeId: string; -}) { - const fs = yield* FileSystem.FileSystem; - - for (let attempt = 1; ; attempt++) { - const { raw, settings } = yield* readSettingsObject(input.settingsPath); - const setAt = DateTime.formatIso(yield* DateTime.now); - const next = +const writeDefaultTheme = (input: { readonly settingsPath: string; readonly themeId: string }) => + updateSettingsObject(input.settingsPath, (settings) => + Effect.map(DateTime.now, (now) => input.themeId.length > 0 ? // The timestamp lets clients reapply the same theme. - { ...settings, defaultTheme: input.themeId, defaultThemeSetAt: setAt } + { ...settings, defaultTheme: input.themeId, defaultThemeSetAt: DateTime.formatIso(now) } : Object.fromEntries( Object.entries(settings).filter( ([key]) => key !== "defaultTheme" && key !== "defaultThemeSetAt", ), - ); - - const contents = yield* encodeSettingsJson(next); - const current = yield* fs - .readFileString(input.settingsPath) - .pipe(Effect.orElseSucceed(() => "")); - if (current !== raw) { - if (attempt >= CONCURRENT_WRITE_ATTEMPTS) { - return yield* new ThemeSettingsBusyError({ - settingsPath: input.settingsPath, - attempts: CONCURRENT_WRITE_ATTEMPTS, - }); - } - continue; - } - - yield* writeFileStringAtomically({ - filePath: input.settingsPath, - contents: `${contents}\n`, - }).pipe( - Effect.mapError( - (cause) => new ThemeSettingsWriteError({ settingsPath: input.settingsPath, cause }), - ), - ); - return; - } -}); + ), + ), + ); const publishThemeFile = Effect.fn(function* (input: { readonly themesDir: string; diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index ca1d86b8ddadb..4f41c0c13e74c 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -55,6 +55,20 @@ it.effect("derives direct Clerk OAuth endpoints from statically injected public }), ); +it("the telemetry setting disables relay tracing unless the variable enables it", () => { + const fallback = { + tracesUrl: "https://embedded.example.test/v1/traces", + tracesDataset: "embedded-dataset", + tracesToken: "embedded-token", + }; + + assert.equal(resolveRelayClientTracingConfig({}, fallback, false), null); + assert.deepEqual( + resolveRelayClientTracingConfig({ T3CODE_TELEMETRY_ENABLED: "true" }, fallback, false), + fallback, + ); +}); + it("product opt-out disables relay tracing with either built-in or runtime credentials", () => { const fallback = { tracesUrl: "https://embedded.example.test/v1/traces", diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 85ffbbb046588..a57d3fa71e985 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -86,8 +86,9 @@ const buildTimeRelayClientTracing = { export function resolveRelayClientTracingConfig( env: Readonly> = process.env, fallback = buildTimeRelayClientTracing, + telemetrySetting = true, ) { - if (!isRelayTracingEnabled(env)) { + if (!isRelayTracingEnabled(env, telemetrySetting)) { return null; } diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index c31c6b2691176..58e0251aaaf89 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -10,19 +10,28 @@ import * as ServerConfig from "../config.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import { resolveRelayClientTracingConfig } from "./publicConfig.ts"; -const relayClientTracingConfig = resolveRelayClientTracingConfig(); - -/** Relay traces leave the machine for T3 Connect, so a policy with `telemetry: false` stops them. */ +/** + * Relay traces leave the machine for T3 Connect, so a policy with `telemetry: false` stops them. + * Without `tracingConfig`, the environment and the telemetry setting read at startup decide. + */ export const relayLayer = ( resource: Omit, - tracingConfig = relayClientTracingConfig, + tracingConfig?: ReturnType, ) => Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const policy = yield* ManagedPolicy.ManagedPolicy; return RelayTracing.layer( - ManagedPolicy.deniedBy(policy, "telemetry") === undefined ? tracingConfig : null, + ManagedPolicy.deniedBy(policy, "telemetry") === undefined + ? tracingConfig === undefined + ? resolveRelayClientTracingConfig( + process.env, + undefined, + config.telemetryEnabledInSettings, + ) + : tracingConfig + : null, { ...resource, serviceInstanceId: processServiceInstanceId(config.otelEnvironment.resourceAttributes), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 1acad23117c8d..176ea486230ac 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -82,6 +82,10 @@ export class ServerConfig extends Context.Service< readonly otlpLogsSource?: OtlpEndpointSource; readonly otlpLogsVariable?: string; readonly otelEnvironment: OtelEnvironment.OtelEnvironment; + /** `T3CODE_TELEMETRY_ENABLED` when set; it overrides the `telemetryEnabled` setting. */ + readonly telemetryEnabledByEnvironment?: boolean | undefined; + /** The `telemetryEnabled` setting at startup, for relay tracing, which cannot change later. */ + readonly telemetryEnabledInSettings?: boolean | undefined; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2c79189878e8a..941013c474088 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -239,6 +239,7 @@ export const make = Effect.gen(function* () { databaseStorage: true, otlpEndpointCheck: true, secretSourceCheck: true, + telemetrySetting: true, projectWorktreeCleanup: true, settledWorktreeCleanup: true, worktreesDirectory: true, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9353fa2f508a8..205cdd5621e6c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -780,8 +780,13 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provideMerge(layerResourceDiagnostics), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(TriageService.layer.pipe(Layer.provide(GitVcsDriver.layer))), - // Layers memoize by reference, so this is the same policy the settings layer reads. - Layer.provideMerge(AnalyticsService.layer.pipe(Layer.provide(ManagedPolicy.layer))), + // Layers memoize by reference, so this is the same policy and settings the settings layer reads. + Layer.provideMerge( + AnalyticsService.layer.pipe( + Layer.provide(layerServerSettings), + Layer.provide(ManagedPolicy.layer), + ), + ), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), Layer.provideMerge(DirectEndpoints.layer.pipe(Layer.provide(layerServerEnvironment))), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 3ca2d29da7c81..89dd612866592 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -2386,6 +2386,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" const settings = yield* serverSettings.getSettings; assert.isFalse(settings.projectSettingsFolded); assert.isFalse(settings.continueThreadsAfterServerUpdate); + assert.isFalse(settings.telemetryEnabled); assert.deepEqual(settings.projectSettingsOverrides, {}); assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4dc30d29a6cff..87244c11b957d 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -805,8 +805,12 @@ const make = Effect.gen(function* () { } if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : legacySettings; - // The file may hold an opt-out we can no longer read; never resume threads on a guess. - settings = { ...managed.defaults, continueThreadsAfterServerUpdate: false }; + // The file may hold an opt-out we can no longer read; never resume threads or send telemetry on a guess. + settings = { + ...managed.defaults, + continueThreadsAfterServerUpdate: false, + telemetryEnabled: false, + }; if (failure._tag === "Failure") { parseFailure = Option.some(Cause.squash(failure.cause)); yield* Effect.logWarning("failed to parse settings.json, using defaults", { diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 4d3c2d5421279..5099c1b1c91c0 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -8,8 +8,10 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; @@ -18,12 +20,14 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as HttpServer from "effect/http/HttpServer"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import type { ServerSettings as ServerSettingsValue } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import type { ManagedPolicyDocument } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { getTelemetryIdentity } from "./Identify.ts"; import * as AnalyticsService from "./AnalyticsService.ts"; @@ -113,15 +117,44 @@ const recordTwentyEvents = Effect.gen(function* () { } }); -const analyticsLayerWith = (client: HttpClient.HttpClient, policy?: ManagedPolicyDocument) => - AnalyticsService.layer.pipe( +/** `ServerSettings.layerTest`, plus the change stream the file-backed service publishes on save. */ +const layerSettingsWithChanges = (overrides: Parameters[0]) => + Layer.effect( + ServerSettings.ServerSettingsService, + Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + const changes = yield* PubSub.unbounded(); + return ServerSettings.ServerSettingsService.of({ + ...settings, + updateSettings: (patch) => + settings.updateSettings(patch).pipe(Effect.tap((next) => PubSub.publish(changes, next))), + streamChanges: Stream.fromPubSub(changes), + subscribeChanges: Effect.map(PubSub.subscribe(changes), Stream.fromSubscription), + }); + }), + ).pipe(Layer.provide(ServerSettings.layerTest(overrides))); + +interface AnalyticsTestOptions { + readonly variable?: boolean | null; + readonly setting?: boolean; + readonly policy?: ManagedPolicyDocument; +} + +const analyticsLayerWith = (client: HttpClient.HttpClient, options: AnalyticsTestOptions = {}) => { + const variable = options.variable === undefined ? true : options.variable; + return AnalyticsService.layer.pipe( Layer.provideMerge( ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-retry-" }), ), + Layer.provideMerge( + layerSettingsWithChanges( + options.setting === undefined ? {} : { telemetryEnabled: options.setting }, + ), + ), Layer.provide( ConfigProvider.layer( ConfigProvider.fromUnknown({ - T3CODE_TELEMETRY_ENABLED: true, + ...(variable === null ? {} : { T3CODE_TELEMETRY_ENABLED: variable }), T3CODE_POSTHOG_KEY: "phc_test_key", T3CODE_POSTHOG_HOST: "http://localhost", }), @@ -132,15 +165,16 @@ const analyticsLayerWith = (client: HttpClient.HttpClient, policy?: ManagedPolic Layer.succeed(HostProcess.Platform, "win32"), Layer.succeed(HostProcess.Architecture, "x64"), Layer.succeed(HttpClient.HttpClient, client), - ManagedPolicy.layerTest(policy), + ManagedPolicy.layerTest(options.policy), ), ), ); +}; /** - * Runs `record` (20 events by default), then lets `minutes` of background - * flushes run against a client that answers every send with `respond`. - * Returns the sent batches. + * Runs `record` (20 events by default), then `minutes` of background flushes against `respond`. + * `variable` sets `T3CODE_TELEMETRY_ENABLED` (`true` by default, `null` unset), `setting` the + * setting, and `policy` the managed policy. Returns the sent batches. */ const sendFor = ( minutes: number, @@ -148,9 +182,11 @@ const sendFor = ( record: Effect.Effect< void, never, - AnalyticsService.AnalyticsService | Effect.Services + | AnalyticsService.AnalyticsService + | ServerSettings.ServerSettingsService + | Effect.Services > = recordTwentyEvents, - policy?: ManagedPolicyDocument, + options: AnalyticsTestOptions = {}, ) => Effect.gen(function* () { const batches: Array> = []; @@ -165,7 +201,7 @@ const sendFor = ( return yield* respond(request); }), ); - const runtimeLayer = analyticsLayerWith(client, policy); + const runtimeLayer = analyticsLayerWith(client, options); yield* Effect.gen(function* () { yield* record; @@ -203,6 +239,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService }), ); + it.effect("after desktop shell discovery, only a shell opt-out outranks the setting", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1" }; + const batches = yield* sendFor( + 0, + respondWithStatus(204), + Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + delete env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING; + yield* analytics.record("test.setting-off"); + yield* analytics.flush; + // Clients already show the setting as deciding, so a late shell value cannot turn it back on. + env.T3CODE_TELEMETRY_ENABLED = "true"; + yield* analytics.record("test.late-shell-true"); + yield* analytics.flush; + }), + { variable: null, setting: false }, + ).pipe(Effect.provideService(HostProcess.Environment, env)); + assert.equal(batches.length, 0); + }), + ); + it.effect("client owner reports cannot use up the server's owner cap", () => Effect.gen(function* () { const batches = yield* sendFor( @@ -258,13 +316,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService Effect.gen(function* () { const policy = { schemaVersion: 1, organization: "Example Corp" } as const; const allowed = yield* sendFor(1, respondWithStatus(204), recordTwentyEvents, { - ...policy, - limits: { telemetry: true }, + policy: { ...policy, limits: { telemetry: true } }, }); assert.equal(allowed.length, 1); const denied = yield* sendFor(1, respondWithStatus(204), recordTwentyEvents, { - ...policy, - limits: { telemetry: false }, + policy: { ...policy, limits: { telemetry: false } }, }); assert.deepEqual(denied, []); }), @@ -292,6 +348,115 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService }), ); + it.effect("the setting turns usage events off while the variable is unset", () => + Effect.gen(function* () { + const batches = yield* sendFor(1, respondWithStatus(204), recordTwentyEvents, { + variable: null, + setting: false, + }); + assert.equal(batches.length, 0); + }), + ); + + it.effect("the variable overrides the setting", () => + Effect.gen(function* () { + const batches = yield* sendFor(1, respondWithStatus(204), recordTwentyEvents, { + variable: true, + setting: false, + }); + assert.equal(batches.flat().length, 20); + }), + ); + + it.effect("turning the setting off discards events still waiting to be sent", () => + Effect.gen(function* () { + const batches = yield* sendFor( + 1, + respondWithStatus(204), + Effect.gen(function* () { + yield* recordTwentyEvents; + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ telemetryEnabled: false }).pipe(Effect.orDie); + const analytics = yield* AnalyticsService.AnalyticsService; + yield* analytics.record("test.after-opt-out"); + }), + { variable: null }, + ); + assert.equal(batches.length, 0); + }), + ); + + it.effect("turning the setting off and back on before a flush discards what was queued", () => + Effect.gen(function* () { + const batches = yield* sendFor( + 1, + respondWithStatus(204), + Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + const settings = yield* ServerSettings.ServerSettingsService; + yield* analytics.record("test.before-opt-out"); + yield* settings.updateSettings({ telemetryEnabled: false }).pipe(Effect.orDie); + yield* settings.updateSettings({ telemetryEnabled: true }).pipe(Effect.orDie); + yield* analytics.record("test.after-opt-in"); + }), + { variable: null }, + ); + assert.notInclude( + batches.flat().map((event) => event.event), + "test.before-opt-out", + ); + }), + ); + + it.effect("turning the setting off and on during a flush stops the batches after it", () => + Effect.gen(function* () { + let settings: ServerSettings.ServerSettingsService["Service"] | undefined; + const batches = yield* sendFor( + 1, + (request) => + Effect.gen(function* () { + yield* settings!.updateSettings({ telemetryEnabled: false }).pipe(Effect.orDie); + yield* settings!.updateSettings({ telemetryEnabled: true }).pipe(Effect.orDie); + // Let the settings listener see both changes before this request completes. + for (let turn = 0; turn < 10; turn += 1) yield* Effect.yieldNow; + return yield* respondWithStatus(204)(request); + }), + Effect.gen(function* () { + settings = yield* ServerSettings.ServerSettingsService; + const analytics = yield* AnalyticsService.AnalyticsService; + for (let index = 0; index < 45; index += 1) { + yield* analytics.record("test.batches", { index }); + } + }), + { variable: null }, + ); + assert.equal(batches.length, 1); + }), + ); + + it.effect("turning the setting off during a flush stops the batches after it", () => + Effect.gen(function* () { + let settings: ServerSettings.ServerSettingsService["Service"] | undefined; + const batches = yield* sendFor( + 1, + (request) => + Effect.gen(function* () { + yield* settings!.updateSettings({ telemetryEnabled: false }).pipe(Effect.orDie); + return yield* respondWithStatus(204)(request); + }), + Effect.gen(function* () { + settings = yield* ServerSettings.ServerSettingsService; + const analytics = yield* AnalyticsService.AnalyticsService; + for (let index = 0; index < 45; index += 1) { + yield* analytics.record("test.batches", { index }); + } + }), + { variable: null }, + ); + assert.equal(batches.length, 1); + }), + ); + it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const capturedRequests: Array = []; @@ -299,7 +464,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService prefix: "t3-telemetry-base-", }); - const layerTelemetry = AnalyticsService.layer.pipe(Layer.provideMerge(layerServerConfig)); + const layerTelemetry = AnalyticsService.layer.pipe( + Layer.provideMerge(layerServerConfig), + Layer.provide(ServerSettings.layerTest()), + ); const layerConfig = ConfigProvider.layer( ConfigProvider.fromUnknown({ T3CODE_TELEMETRY_ENABLED: true, @@ -401,7 +569,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService const layerServerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-disabled-", }); - const layerTelemetry = AnalyticsService.layer.pipe(Layer.provideMerge(layerServerConfig)); + const layerTelemetry = AnalyticsService.layer.pipe( + Layer.provideMerge(layerServerConfig), + Layer.provide(ServerSettings.layerTest()), + ); const layerConfig = ConfigProvider.layer( ConfigProvider.fromUnknown({ T3CODE_TELEMETRY_ENABLED: false, diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 384da92b3f312..27e288bf0f9f1 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -15,6 +15,7 @@ import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -22,6 +23,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { getTelemetryIdentity, type TelemetryOwner } from "./Identify.ts"; interface BufferedAnalyticsEvent { @@ -71,7 +73,7 @@ const TelemetryEnvConfig = Config.all({ posthogHost: Config.String("T3CODE_POSTHOG_HOST").pipe( Config.withDefault("https://ingest.t3.codes"), ), - enabled: Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)), + enabled: Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.option), flushBatchSize: Config.Number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)), maxBufferedEvents: Config.Number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( Config.withDefault(1_000), @@ -116,35 +118,49 @@ function serverOsFromNodePlatform(platform: string): ClientOs { } } +/** + * Whether `T3CODE_TELEMETRY_ENABLED` in `env` turns telemetry off. A value that does not parse counts + * as off. Reads the live environment, which desktop shell discovery updates after startup. + */ +export const environmentOptsOut = (env: NodeJS.ProcessEnv): Effect.Effect => { + const value = env.T3CODE_TELEMETRY_ENABLED; + if (value === undefined) return Effect.succeed(false); + return Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env: { T3CODE_TELEMETRY_ENABLED: value } }), + ), + Effect.map((enabled) => !enabled), + Effect.orElseSucceed(() => true), + ); +}; + /** @public */ /** @public */ export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; const hostEnvironment = yield* HostProcess.Environment; const awaitingShellConsent = hostEnvironment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING === "1"; - // A managed policy can switch analytics off over the environment, never on. + // A managed policy can switch analytics off over the environment and the setting, never on. const policyAllows = ManagedPolicy.deniedBy(yield* ManagedPolicy.ManagedPolicy, "telemetry") === undefined; - // A cached PATH does not establish consent from the current login shell. - const telemetryEnabled = Effect.suspend(() => { - if (!telemetryConfig.enabled || !policyAllows) return Effect.succeed(false); - if (!awaitingShellConsent) return Effect.succeed(true); + // What decides over the setting, when anything does: the policy's opt-out, then the variable. + // A desktop launch waits for the login shell, since a cached PATH does not establish consent from + // the current login shell. Clients were told the setting decides by then, so the shell's value + // can only opt out. + const variableEnabled = Effect.suspend((): Effect.Effect> => { + if (!policyAllows || Option.contains(telemetryConfig.enabled, false)) + return Effect.succeedSome(false); + if (!awaitingShellConsent) return Effect.succeed(telemetryConfig.enabled); if (hostEnvironment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING === "1") - return Effect.succeed(false); - const preference = hostEnvironment.T3CODE_TELEMETRY_ENABLED; - return Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe( - Config.withDefault(true), - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnv({ - env: preference === undefined ? {} : { T3CODE_TELEMETRY_ENABLED: preference }, - }), - ), - Effect.orElseSucceed(() => false), + return Effect.succeedSome(false); + return environmentOptsOut(hostEnvironment).pipe( + Effect.map((optsOut) => (optsOut ? Option.some(false) : telemetryConfig.enabled)), ); }); const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; const { distinctId, installId } = yield* getTelemetryIdentity; const crypto = yield* SecureRandom.SecureRandom; const bufferRef = yield* Ref.make>([]); @@ -164,6 +180,20 @@ export const make = Effect.gen(function* () { const hostPlatform = yield* HostProcess.Platform; const hostArchitecture = yield* HostProcess.Architecture; + // The variable wins when set; otherwise the setting decides, so turning it off applies at once. + // An unreadable setting counts as off: an opt-out must not be lost to a read failure. + const isEnabled = Effect.flatMap( + variableEnabled, + Option.match({ + onSome: Effect.succeed, + onNone: () => + serverSettings.getSettings.pipe( + Effect.map((settings) => settings.telemetryEnabled), + Effect.orElseSucceed(() => false), + ), + }), + ); + const enqueueBufferedEvent = ( uuid: string, event: string, @@ -199,7 +229,7 @@ export const make = Effect.gen(function* () { const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* ( events: ReadonlyArray, ) { - if (!(yield* telemetryEnabled) || !distinctId) return; + if (!distinctId) return; const payload = { api_key: telemetryConfig.posthogKey, @@ -240,9 +270,28 @@ export const make = Effect.gen(function* () { return [nextBatch, current.slice(nextBatch.length)] as const; }); + // Counts opt-outs as they land, without waiting for a flush in progress to release its lock. + const optOutsRef = yield* Ref.make(0); + // Nothing recorded before an opt-out is sent after it, even if telemetry is turned back on. + const discardQueued = Effect.andThen( + Ref.set(bufferRef, []), + Ref.update(deliveryRef, (delivery) => ({ + ...delivery, + failedBatch: [], + batchAttempts: 0, + firstFailedAt: 0, + })), + ); + // Back off even after dropping rejected batches so bad API keys cannot drain the queue in a tight loop. const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { + const optOutsAtStart = yield* Ref.get(optOutsRef); while (true) { + // An opt-out during this flush stops it even if telemetry is already back on. + if (!(yield* isEnabled) || (yield* Ref.get(optOutsRef)) !== optOutsAtStart) { + yield* discardQueued; + return; + } const delivery = yield* Ref.get(deliveryRef); const batch = delivery.failedBatch.length > 0 ? delivery.failedBatch : yield* takeBatch; if (batch.length === 0) { @@ -311,7 +360,7 @@ export const make = Effect.gen(function* () { const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( function* (event, properties) { - if (!(yield* telemetryEnabled) || !distinctId) return; + if (!distinctId || !(yield* isEnabled)) return; // Catch Node UUID defects too; best-effort telemetry drops events without UUIDs. const uuid = yield* Effect.exit(crypto.uuidV7()); @@ -350,6 +399,24 @@ export const make = Effect.gen(function* () { ), ); + // Discard as soon as the setting turns telemetry off, not at the next flush, which may come after + // telemetry is back on. The lock waits out a send in flight so its batch cannot be kept for retry. + const settingsChanges = yield* serverSettings.subscribeChanges; + yield* settingsChanges.pipe( + // Use each change's own value: by the time it is handled, telemetry may be back on. + Stream.runForEach((settings) => + Effect.flatMap(variableEnabled, (variable) => + Option.getOrElse(variable, () => settings.telemetryEnabled) + ? Effect.void + : Effect.andThen( + Ref.update(optOutsRef, (count) => count + 1), + discardQueued.pipe(flushLock.withPermit), + ), + ), + ), + Effect.forkScoped, + ); + yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.andThen(flushWhenDue)), { disableYield: true, }).pipe(Effect.forkScoped); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1f2a148734d4e..541d42142027f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -266,6 +266,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { isNetworkPath } from "@t3tools/shared/path"; import * as RelayClient from "@t3tools/shared/relayClient"; import { @@ -1813,6 +1814,12 @@ export const layerWsRpc = ( settingsClient, ); const environment = yield* serverEnvironment.getDescriptor; + // A desktop launch can learn a shell opt-out after startup; report it like AnalyticsService applies it. + const telemetryEnabledByEnvironment = + config.telemetryEnabledByEnvironment === false || + (yield* AnalyticsService.environmentOptsOut(yield* HostProcess.Environment)) + ? false + : config.telemetryEnabledByEnvironment; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; const lastEventLoopStall = yield* eventLoopMonitor.latestNoticeableStall; @@ -1863,6 +1870,9 @@ export const layerWsRpc = ( } : {}), otlpLogsEnabled: config.otlpLogsUrl !== undefined, + ...(telemetryEnabledByEnvironment === undefined + ? {} + : { telemetryEnabledByEnvironment }), }, settings, ...(serverSettings.managedPolicy === undefined diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 0c6695870f9a2..284a6a46efb98 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -12,6 +12,7 @@ import { getChangedBrowserSettingLabels, hasChangedBackgroundActivitySettings, syncAutoSettleDaysField, + telemetrySettingLocks, } from "./SettingsPanels.logic"; describe("background activity settings restore", () => { @@ -172,3 +173,61 @@ describe("auto-settle days field", () => { expect(syncAutoSettleDaysField(typing, 30)).toMatchObject({ saved: 30, draft: "4" }); }); }); + +describe("telemetry setting locks", () => { + const observability = (telemetryEnabledByEnvironment?: boolean) => ({ + logsDirectoryPath: "/logs", + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + otlpLogsEnabled: false, + ...(telemetryEnabledByEnvironment === undefined ? {} : { telemetryEnabledByEnvironment }), + }); + const policy = (telemetry: boolean) => ({ + organization: "Example Corp", + lockedSettings: [], + limits: { telemetry }, + }); + + it("leaves the setting in charge where neither a policy nor the variable decides", () => { + expect( + telemetrySettingLocks([ + { label: "Laptop", serverConfig: { observability: observability() } }, + ]), + ).toEqual([]); + }); + + it("reports the variable's value with its environment", () => { + expect( + telemetrySettingLocks([ + { label: "Laptop", serverConfig: { observability: observability(false) } }, + ]), + ).toEqual([ + { value: false, reason: "Set by T3CODE_TELEMETRY_ENABLED=false on Laptop.", managed: false }, + ]); + }); + + it("lets a policy that turns telemetry off win over the variable", () => { + expect( + telemetrySettingLocks([ + { + label: "Laptop", + serverConfig: { observability: observability(true), managedPolicy: policy(false) }, + }, + ]), + ).toEqual([ + { value: false, reason: "Example Corp turned off anonymous usage data.", managed: true }, + ]); + }); + + it("ignores a policy that allows telemetry", () => { + expect( + telemetrySettingLocks([ + { + label: "Laptop", + serverConfig: { observability: observability(), managedPolicy: policy(true) }, + }, + ]), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 1579cad9a8af2..73647184bb6ca 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -4,6 +4,7 @@ import type { ProviderInstanceConfig, PreviewViewportSetting, ProviderInstanceId, + ServerConfig, ServerSettings, SidebarProjectGroupingMode, UnifiedSettings, @@ -22,6 +23,8 @@ import { import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; +import { managedTelemetryOffMessage } from "../../state/managedPolicy"; + export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { return mode !== "separate"; } @@ -345,3 +348,36 @@ export function failAutoSettleDaysField( if (field.revision !== revision) return field; return { ...field, pending: null, draft: field.dirty ? field.draft : String(field.saved) }; } + +export interface TelemetrySettingLock { + readonly value: boolean; + readonly reason: string; + /** Set when an administrator's policy, not the environment variable, decides. */ + readonly managed: boolean; +} + +/** + * Where something decides over the `telemetryEnabled` setting, per environment: a managed policy + * that turns telemetry off, else `T3CODE_TELEMETRY_ENABLED` as the server reports it. + */ +export function telemetrySettingLocks( + environments: ReadonlyArray<{ + readonly label: string; + readonly serverConfig?: Pick | null; + }>, +): TelemetrySettingLock[] { + return environments.flatMap((environment): TelemetrySettingLock[] => { + const policyOff = managedTelemetryOffMessage(environment.serverConfig?.managedPolicy); + if (policyOff !== undefined) return [{ value: false, reason: policyOff, managed: true }]; + const value = environment.serverConfig?.observability.telemetryEnabledByEnvironment; + return value === undefined + ? [] + : [ + { + value, + reason: `Set by T3CODE_TELEMETRY_ENABLED=${value} on ${environment.label}.`, + managed: false, + }, + ]; + }); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ea874c2b18c30..028909a9c395a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -102,6 +102,7 @@ import { readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, syncAutoSettleDaysField, + telemetrySettingLocks, } from "./SettingsPanels.logic"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, @@ -2283,6 +2284,57 @@ export function MaintenanceSettingsPanel() { ); } +function ProductUsageDataRow() { + return useOptionalSettingsScope() === null ? null : ; +} + +function ScopedProductUsageDataRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const { connectedEnvironments } = useSettingsScope(); + // Older servers ignore the setting, so a switch there would claim an opt-out that never happens. + if ( + !connectedEnvironments.every( + (target) => target.serverConfig?.environment.capabilities.telemetrySetting === true, + ) + ) { + return null; + } + // A policy or the variable decides over the setting; where one does, the switch shows its value. + const locks = telemetrySettingLocks(connectedEnvironments); + const lockedEverywhere = + connectedEnvironments.length > 0 && locks.length === connectedEnvironments.length; + return ( + 0 ? [...new Set(locks.map(({ reason }) => reason))].join(" ") : undefined + } + managed={lockedEverywhere ? locks.find((lock) => lock.managed)?.reason : undefined} + control={ + lockedEverywhere ? ( + // Persisted values do not decide here, so they must not make the switch look mixed. + value)} + disabled + aria-label="Product usage data" + /> + ) : ( + updateSettings({ telemetryEnabled: Boolean(checked) })} + aria-label="Product usage data" + /> + ) + } + /> + ); +} + export function AboutSettingsPanel() { const primaryPolicy = usePrimaryManagedPolicy(); // The Welcome page sets up this computer, so it needs one. @@ -2353,6 +2405,7 @@ export function AboutSettingsPanel() { } /> + { "auto-settle-merged-threads", "days-before-auto-settle", "linked-environments", + "product-usage-data", ]); expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); @@ -196,6 +197,23 @@ describe("searchSettings", () => { expect(ids(true)).toContain("welcome-page"); }); + it("finds product usage data only where a server supports the setting", () => { + const availability = { + hasCloudPublicConfig: false, + hasEnvironment: true, + hasProviderSettingsEnvironment: true, + canManageLocalBackend: false, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, + }; + const ids = (hasTelemetrySetting: boolean) => + filterAvailableSettingsSearchItems({ ...availability, hasTelemetrySetting }).map( + (item) => item.id, + ); + expect(ids(false)).not.toContain("product-usage-data"); + expect(ids(true)).toContain("product-usage-data"); + }); + it("finds keybinding commands by label, command id, and default key", () => { expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle"); expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle"); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 85e4878be73dd..f29bfb682c92f 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -74,6 +74,7 @@ export interface SettingsSearchItem { readonly textGeneratorsOnly?: boolean; readonly secondary?: boolean; readonly requiresThreadAutoSettlement?: boolean; + readonly telemetrySettingOnly?: boolean; } export interface SettingsSearchAvailability { @@ -90,6 +91,7 @@ export interface SettingsSearchAvailability { /** An environment without a provider allow-list, which hides text generators. */ readonly hasTextGeneratorsEnvironment?: boolean; readonly hasPrimaryEnvironment?: boolean; + readonly hasTelemetrySetting?: boolean; } export const SETTINGS_SECTION_LABELS: Readonly> = { @@ -906,6 +908,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/about", searchTerms: ["telemetry analytics usage data tracking legal opt out"], }, + { + id: "product-usage-data", + title: "Product usage data", + to: "/settings/about", + scope: "environment", + telemetrySettingOnly: true, + searchTerms: ["telemetry analytics usage data tracking opt out disable posthog traces"], + }, { id: "telemetry-export", title: "OpenTelemetry export", @@ -1516,7 +1526,8 @@ export function filterAvailableSettingsSearchItems( (!item.peerLinksOnly || availability.hasPeerLinkHost === true) && (!item.fastWorktreeCheckoutOnly || availability.hasFastWorktreeCheckoutEnvironment === true) && - (!item.textGeneratorsOnly || availability.hasTextGeneratorsEnvironment === true), + (!item.textGeneratorsOnly || availability.hasTextGeneratorsEnvironment === true) && + (!item.telemetrySettingOnly || availability.hasTelemetrySetting === true), ); } diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index 8dc0bce3a75f0..4f1b7b6d1268c 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -41,6 +41,12 @@ export function useAvailableSettingsSearchItems() { environment.serverConfig !== null && environment.serverConfig.managedPolicy?.limits?.providers?.allowedDrivers === undefined, ) === true; + const hasTelemetrySetting = + environments.some( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.telemetrySetting === true, + ) === true; const hasThreadAutoSettlement = getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0; const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -73,5 +79,6 @@ export function useAvailableSettingsSearchItems() { hasFastWorktreeCheckoutEnvironment, hasTextGeneratorsEnvironment, hasPrimaryEnvironment: primaryEnvironmentId !== null, + hasTelemetrySetting, }); } diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md index 2f2ccc8932769..1fbd0fdc1c63d 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -19,9 +19,16 @@ separate from product events. Depending on the client version, traces can includ timings, environment identifiers and the connected machine's name. Failed spans can include error messages and stack traces; error text can quote a value the app was handling. -To disable product events and relay tracing from the server, set -`T3CODE_TELEMETRY_ENABLED=false` in its environment before starting it. Relay tracing also stops -when `T3CODE_OTEL_SDK_DISABLED` or `OTEL_SDK_DISABLED` turns OpenTelemetry export off. +To disable product events and relay tracing for an environment, turn off **Product usage data** in +**Settings → About** with that environment selected, or run `t3 telemetry off` on its machine +(`t3 telemetry on` turns it back on, `t3 telemetry status` shows it). Product events stop at once; +relay tracing stops after the server restarts, and the desktop app's own relay tracing after the app +restarts. If the settings file cannot be read, T3 Code treats the setting as off until it is fixed. + +`T3CODE_TELEMETRY_ENABLED` in the server's environment overrides that setting: `false` disables both +whatever the setting says, and `true` enables them. In the desktop app, a value that comes from your +login shell can only turn telemetry off. Settings shows when the variable decides. Relay tracing also stops when `T3CODE_OTEL_SDK_DISABLED` or +`OTEL_SDK_DISABLED` turns OpenTelemetry export off. An administrator can turn product events and relay tracing off in the server and desktop app with a [managed policy](./managed-policy.md#other-capabilities). This does not control traces sent by diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index e305efb51f838..53331ca69d4fc 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -109,6 +109,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ otlpEndpointCheck: Schema.optionalKey(Schema.Boolean), /** Serves `server.checkSecretSource`; hide the secret check where it is absent. */ secretSourceCheck: Schema.optionalKey(Schema.Boolean), + /** Hide the switch when absent; older servers ignore `telemetryEnabled`. */ + telemetrySetting: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), settledWorktreeCleanup: Schema.optionalKey(Schema.Boolean), worktreesDirectory: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e8bcda3a76f5d..71f12cef2f772 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -366,6 +366,8 @@ export const ServerObservability = Schema.Struct({ otlpLogsSource: Schema.optional(OtlpEndpointSource), otlpLogsVariable: Schema.optional(TrimmedNonEmptyString), otlpLogsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** `T3CODE_TELEMETRY_ENABLED` when set; it overrides the `telemetryEnabled` setting. */ + telemetryEnabledByEnvironment: Schema.optional(Schema.Boolean), }); export type ServerObservability = typeof ServerObservability.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 49470541d90be..98479a4adf136 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1467,6 +1467,11 @@ export const ServerSettings = Schema.Struct({ linkedEnvironmentsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** Beta: Claude and Codex predict the next prompt after completed turns. Codex follows a change at once; open Claude threads after they restart. */ promptSuggestionsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** + * Product usage events and relay connection traces. `T3CODE_TELEMETRY_ENABLED` overrides it. + * Usage events follow changes at once; relay traces follow them after a restart. + */ + telemetryEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarAutoSettleKeepPullRequests: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -1890,6 +1895,7 @@ export const ServerSettingsPatch = Schema.Struct({ guestSharingEnabled: Schema.optionalKey(Schema.Boolean), linkedEnvironmentsEnabled: Schema.optionalKey(Schema.Boolean), promptSuggestionsEnabled: Schema.optionalKey(Schema.Boolean), + telemetryEnabled: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ schemaVersion: Schema.optionalKey(Schema.Literal(1)), diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index 21df52609ff29..c3cbc100e021a 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -404,6 +404,22 @@ describe("isRelayTracingEnabled", () => { ); }); + it("follows the telemetry setting unless T3CODE_TELEMETRY_ENABLED is set", () => { + assert.isFalse(OtelEnvironment.isRelayTracingEnabled({}, false)); + assert.isTrue( + OtelEnvironment.isRelayTracingEnabled({ T3CODE_TELEMETRY_ENABLED: "true" }, false), + ); + assert.isFalse( + OtelEnvironment.isRelayTracingEnabled({ T3CODE_TELEMETRY_ENABLED: "false" }, true), + ); + assert.isFalse( + OtelEnvironment.isRelayTracingEnabled( + { T3CODE_TELEMETRY_ENABLED: "true", OTEL_SDK_DISABLED: "true" }, + true, + ), + ); + }); + it.effect("product opt-out beats SDK enable without disabling user-configured tracing", () => Effect.gen(function* () { const env = { diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 95392206a8c7c..8380c27245f0b 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -318,13 +318,18 @@ export const load: Effect.Effect = Config.all({ Effect.orDie, ); -export const isRelayTracingEnabled = (env: Readonly>) => +/** `T3CODE_TELEMETRY_ENABLED` wins over the `telemetryEnabled` setting when set. */ +export const isRelayTracingEnabled = ( + env: Readonly>, + telemetrySetting = true, +) => Config.all({ ...sdkFlags, telemetry: flag("T3CODE_TELEMETRY_ENABLED", T3CODE_TRUE, T3CODE_FALSE, String), }).pipe( Effect.map( - ({ telemetry, t3, spec }) => telemetry.value !== false && !(t3.value ?? spec.value ?? false), + ({ telemetry, t3, spec }) => + (telemetry.value ?? telemetrySetting) && !(t3.value ?? spec.value ?? false), ), Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(env))), Effect.orDie, diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 9836fe10fc77f..f95499c413e5c 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -278,6 +278,7 @@ describe("serverSettings helpers", () => { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", otlpLogsUrl: "http://localhost:4318/v1/logs", + telemetryEnabled: true, }); // The server still loads a file holding the retired token streaming mode. expect( @@ -290,12 +291,27 @@ describe("serverSettings helpers", () => { ).toBe("http://localhost:4318/v1/traces"); }); - it("falls back cleanly when persisted settings are invalid", () => { + it("reads a persisted telemetry opt-out", () => { + expect( + parsePersistedServerObservabilitySettings(JSON.stringify({ telemetryEnabled: false })) + .telemetryEnabled, + ).toBe(false); + // The retired token streaming mode must not cost the file its opt-out either. + expect( + parsePersistedServerObservabilitySettings( + JSON.stringify({ responseStreamingMode: "token", telemetryEnabled: false }), + ).telemetryEnabled, + ).toBe(false); + }); + + it("falls back to no endpoints and telemetry off when persisted settings are invalid", () => { expect(parsePersistedServerObservabilitySettings("{")).toEqual({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, + telemetryEnabled: false, }); + expect(parsePersistedServerObservabilitySettings(" \n").telemetryEnabled).toBe(false); }); it("still deep merges text generation selection when only options are provided", () => { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 023c6eb74333e..8014af013b6ea 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -37,9 +37,12 @@ import { resolveBackgroundActivitySettings, } from "./backgroundActivitySettings.ts"; -// Only observability: a value the server migrates on load elsewhere in the file must not drop it. +// Only these fields: a value the server migrates on load elsewhere in the file must not drop them. const ServerObservabilitySettingsJson = fromLenientJson( - Schema.Struct({ observability: ServerSettings.fields.observability }), + Schema.Struct({ + observability: ServerSettings.fields.observability, + telemetryEnabled: ServerSettings.fields.telemetryEnabled, + }), ); const decodeServerObservabilitySettingsJson = Schema.decodeUnknownOption( ServerObservabilitySettingsJson, @@ -178,8 +181,16 @@ export interface PersistedServerObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; readonly otlpLogsUrl: string | undefined; + readonly telemetryEnabled: boolean; } +export const NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS: PersistedServerObservabilitySettings = { + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, + telemetryEnabled: true, +}; + function normalizePersistedServerSettingString( value: string | null | undefined, ): string | undefined { @@ -187,17 +198,14 @@ function normalizePersistedServerSettingString( return trimmed && trimmed.length > 0 ? trimmed : undefined; } -function extractPersistedServerObservabilitySettings(input: { - readonly observability?: { - readonly otlpTracesUrl?: string; - readonly otlpMetricsUrl?: string; - readonly otlpLogsUrl?: string; - }; -}): PersistedServerObservabilitySettings { +function extractPersistedServerObservabilitySettings( + input: Pick, +): PersistedServerObservabilitySettings { return { - otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl), - otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl), - otlpLogsUrl: normalizePersistedServerSettingString(input.observability?.otlpLogsUrl), + otlpTracesUrl: normalizePersistedServerSettingString(input.observability.otlpTracesUrl), + otlpMetricsUrl: normalizePersistedServerSettingString(input.observability.otlpMetricsUrl), + otlpLogsUrl: normalizePersistedServerSettingString(input.observability.otlpLogsUrl), + telemetryEnabled: input.telemetryEnabled, }; } @@ -208,7 +216,8 @@ export function parsePersistedServerObservabilitySettings( if (Option.isSome(decoded)) { return extractPersistedServerObservabilitySettings(decoded.value); } - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; + // The file may hold a telemetry opt-out we can no longer read. + return { ...NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS, telemetryEnabled: false }; } function shouldReplaceTextGenerationModelSelection(