From 5488ba1a71fca4fad24fec4f681ac0119fedb133 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Thu, 29 Oct 2026 20:16:02 +0100 Subject: [PATCH 01/15] feat(server,web,desktop): turn telemetry off in Settings or with `t3 telemetry` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3CODE_TELEMETRY_ENABLED was the only way to stop product usage events and T3 Connect connection traces. A `telemetryEnabled` server setting now does the same, from Settings → About or `t3 telemetry on|off|status`. The variable still wins when set, and Settings shows when it decides. The default stays on. --- apps/desktop/src/app/DesktopObservability.ts | 11 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 5 +- apps/desktop/src/ipc/methods/window.test.ts | 12 +- apps/desktop/src/ipc/methods/window.ts | 7 +- apps/server/src/binCli.ts | 2 + apps/server/src/cli/config.test.ts | 1 + apps/server/src/cli/config.ts | 15 +- apps/server/src/cli/settingsFile.ts | 136 +++++++++++++++++ apps/server/src/cli/telemetry.test.ts | 75 ++++++++++ apps/server/src/cli/telemetry.ts | 74 +++++++++ apps/server/src/cli/theme.ts | 140 ++---------------- apps/server/src/cloud/publicConfig.test.ts | 14 ++ apps/server/src/cloud/publicConfig.ts | 3 +- apps/server/src/cloud/relayTracing.ts | 18 ++- apps/server/src/config.ts | 4 + .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/server.ts | 9 +- .../src/telemetry/AnalyticsService.test.ts | 86 +++++++++-- apps/server/src/telemetry/AnalyticsService.ts | 57 +++++-- apps/server/src/ws.ts | 3 + .../components/settings/SettingsPanels.tsx | 52 +++++++ .../src/components/settings/settingsSearch.ts | 7 + docs/user/telemetry.md | 10 ++ packages/contracts/src/environment.ts | 2 + packages/contracts/src/server.ts | 2 + packages/contracts/src/settings.ts | 6 + packages/shared/src/otelEnvironment.test.ts | 16 ++ packages/shared/src/otelEnvironment.ts | 9 +- packages/shared/src/serverSettings.test.ts | 9 ++ packages/shared/src/serverSettings.ts | 27 ++-- 30 files changed, 620 insertions(+), 193 deletions(-) create mode 100644 apps/server/src/cli/settingsFile.ts create mode 100644 apps/server/src/cli/telemetry.test.ts create mode 100644 apps/server/src/cli/telemetry.ts diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 16192fff46de8..ea3f59b833527 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,13 +331,7 @@ 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 @@ -345,7 +340,7 @@ const readPersistedObservabilitySettings: Effect.Effect< const environment = yield* DesktopEnvironment.DesktopEnvironment; const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); return Option.isNone(raw) - ? noPersistedObservabilitySettings + ? NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS : parsePersistedServerObservabilitySettings(raw.value); }); 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..52008a33c7b0a --- /dev/null +++ b/apps/server/src/cli/settingsFile.ts @@ -0,0 +1,136 @@ +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; +}); + +/** 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 { raw: "", settings: {} }; + + const raw = yield* fs + .readFileString(settingsPath) + .pipe(Effect.mapError((cause) => new SettingsUnreadableError({ settingsPath, cause }))); + if (raw.trim().length === 0) return { raw, settings: {} }; + + const settings = yield* decodeSettingsJson(raw).pipe( + Effect.mapError((cause) => new SettingsMalformedError({ settingsPath, cause })), + ); + return { 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..44ef3b636222e --- /dev/null +++ b/apps/server/src/cli/telemetry.test.ts @@ -0,0 +1,75 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +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, + NetService.layer, + Layer.fresh(TestConsole.layer), + ConfigProvider.layer(ConfigProvider.fromUnknown(env)), + ), + ), + ); + +const makeBaseDir = () => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-telemetry-cli-")); + +const settingsPathFor = (baseDir: string) => NodePath.join(baseDir, "userdata", "settings.json"); + +const readSettings = (baseDir: string): Record => + JSON.parse(NodeFS.readFileSync(settingsPathFor(baseDir), "utf8")) as Record; + +describe("t3 telemetry", () => { + it.effect("turns the setting off and back on, keeping other settings", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); + NodeFS.writeFileSync( + settingsPathFor(baseDir), + `${JSON.stringify({ somethingFromANewerBuild: { nested: true } })}\n`, + ); + + yield* runCli(["telemetry", "off", "--base-dir", baseDir]); + assert.deepEqual(readSettings(baseDir), { + 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(readSettings(baseDir), { somethingFromANewerBuild: { nested: true } }); + const on = yield* runCli(["telemetry", "status", "--base-dir", baseDir]); + assert.isTrue(on.some((line) => String(line).startsWith("Telemetry setting: on"))); + }), + ); + + it.effect("says when the variable in this shell overrides the setting", () => + Effect.gen(function* () { + const baseDir = 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")), + ); + }), + ); +}); diff --git a/apps/server/src/cli/telemetry.ts b/apps/server/src/cli/telemetry.ts new file mode 100644 index 0000000000000..fcd8cf7d3e1d5 --- /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 { settings } = yield* readSettingsObject(paths.settingsPath); + yield* Console.log( + `Telemetry setting: ${settings.telemetryEnabled === false ? "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..c4fef4b8a4d79 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -10,19 +10,27 @@ 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 ?? + resolveRelayClientTracingConfig( + process.env, + undefined, + config.telemetryEnabledInSettings, + )) + : 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/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 4d3c2d5421279..65a85e19a543e 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -24,6 +24,7 @@ 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 +114,27 @@ const recordTwentyEvents = Effect.gen(function* () { } }); -const analyticsLayerWith = (client: HttpClient.HttpClient, policy?: ManagedPolicyDocument) => - AnalyticsService.layer.pipe( +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( + ServerSettings.layerTest( + 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,14 +145,17 @@ 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`. + * `T3CODE_TELEMETRY_ENABLED` is `true` unless `variable` says otherwise; `null` leaves it unset. + * `policy` is the managed policy document, none by default. * Returns the sent batches. */ const sendFor = ( @@ -148,9 +164,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 +183,7 @@ const sendFor = ( return yield* respond(request); }), ); - const runtimeLayer = analyticsLayerWith(client, policy); + const runtimeLayer = analyticsLayerWith(client, options); yield* Effect.gen(function* () { yield* record; @@ -258,13 +276,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 +308,44 @@ 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("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const capturedRequests: Array = []; @@ -299,7 +353,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 +458,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..6e2d7d2e6b6ed 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -22,6 +22,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 +72,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), @@ -122,29 +123,32 @@ 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's value, since a cached PATH does not establish + // consent from the current login shell. + const variableEnabled = Effect.suspend((): Effect.Effect> => { + if (!policyAllows || Option.contains(telemetryConfig.enabled, false)) + return Effect.succeed(Option.some(false)); + if (!awaitingShellConsent) return Effect.succeed(telemetryConfig.enabled); if (hostEnvironment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING === "1") - return Effect.succeed(false); + return Effect.succeed(Option.some(false)); const preference = hostEnvironment.T3CODE_TELEMETRY_ENABLED; + if (preference === undefined) return Effect.succeed(Option.none()); return Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe( - Config.withDefault(true), Effect.provideService( ConfigProvider.ConfigProvider, - ConfigProvider.fromEnv({ - env: preference === undefined ? {} : { T3CODE_TELEMETRY_ENABLED: preference }, - }), + ConfigProvider.fromEnv({ env: { T3CODE_TELEMETRY_ENABLED: preference } }), ), - Effect.orElseSucceed(() => false), + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.some(false)), ); }); 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 +168,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 +217,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, @@ -242,6 +260,17 @@ export const make = Effect.gen(function* () { // 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* () { + if (!(yield* isEnabled)) { + // Nothing recorded before an opt-out is sent after it. + yield* Ref.set(bufferRef, []); + yield* Ref.update(deliveryRef, (delivery) => ({ + ...delivery, + failedBatch: [], + batchAttempts: 0, + firstFailedAt: 0, + })); + return; + } while (true) { const delivery = yield* Ref.get(deliveryRef); const batch = delivery.failedBatch.length > 0 ? delivery.failedBatch : yield* takeBatch; @@ -311,7 +340,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()); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1f2a148734d4e..ee728cfca66ef 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1863,6 +1863,9 @@ export const layerWsRpc = ( } : {}), otlpLogsEnabled: config.otlpLogsUrl !== undefined, + ...(config.telemetryEnabledByEnvironment === undefined + ? {} + : { telemetryEnabledByEnvironment: config.telemetryEnabledByEnvironment }), }, settings, ...(serverSettings.managedPolicy === undefined diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ea874c2b18c30..73d4a7fe4a336 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2283,6 +2283,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; + } + const overrides = connectedEnvironments.flatMap((target) => { + const value = target.serverConfig?.observability.telemetryEnabledByEnvironment; + return value === undefined ? [] : [{ label: target.label, value }]; + }); + // The variable wins over the setting, so where it decides the switch shows its value instead. + const lockedEverywhere = + connectedEnvironments.length > 0 && overrides.length === connectedEnvironments.length; + return ( + 0 + ? overrides + .map(({ label, value }) => `Set by T3CODE_TELEMETRY_ENABLED=${value} on ${label}.`) + .join(" ") + : undefined + } + control={ + value) : settings.telemetryEnabled + } + disabled={lockedEverywhere} + onCheckedChange={(checked) => 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 +2404,7 @@ export function AboutSettingsPanel() { } /> + { ); }); + 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..b94f3e6b86881 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,11 +291,19 @@ describe("serverSettings helpers", () => { ).toBe("http://localhost:4318/v1/traces"); }); + it("reads a persisted telemetry opt-out", () => { + expect( + parsePersistedServerObservabilitySettings(JSON.stringify({ telemetryEnabled: false })) + .telemetryEnabled, + ).toBe(false); + }); + it("falls back cleanly when persisted settings are invalid", () => { expect(parsePersistedServerObservabilitySettings("{")).toEqual({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, + telemetryEnabled: true, }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 023c6eb74333e..e0b47ce34bcdf 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -178,8 +178,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 +195,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 +213,7 @@ export function parsePersistedServerObservabilitySettings( if (Option.isSome(decoded)) { return extractPersistedServerObservabilitySettings(decoded.value); } - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; + return NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS; } function shouldReplaceTextGenerationModelSelection( From 8a11a1046427416343db9c236d2477cf8dfeda53 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Thu, 29 Oct 2026 20:21:47 +0100 Subject: [PATCH 02/15] fix(server,web): stop a running flush on opt-out and keep the locked switch exact Turning the setting off during a flush no longer lets the remaining batches go out, since the setting is now checked before each batch. When the variable decides on every selected environment, the switch no longer turns mixed because of differing saved values it does not use. --- .../src/telemetry/AnalyticsService.test.ts | 23 ++++++++++++++++++ apps/server/src/telemetry/AnalyticsService.ts | 22 ++++++++--------- .../components/settings/SettingsPanels.tsx | 24 ++++++++++++------- 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 65a85e19a543e..9ffc76432fd3c 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -346,6 +346,29 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService }), ); + 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 = []; diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 6e2d7d2e6b6ed..5fd91dc61ba47 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -260,18 +260,18 @@ export const make = Effect.gen(function* () { // 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* () { - if (!(yield* isEnabled)) { - // Nothing recorded before an opt-out is sent after it. - yield* Ref.set(bufferRef, []); - yield* Ref.update(deliveryRef, (delivery) => ({ - ...delivery, - failedBatch: [], - batchAttempts: 0, - firstFailedAt: 0, - })); - return; - } while (true) { + // Checked per batch: nothing recorded before an opt-out is sent after it. + if (!(yield* isEnabled)) { + yield* Ref.set(bufferRef, []); + yield* Ref.update(deliveryRef, (delivery) => ({ + ...delivery, + failedBatch: [], + batchAttempts: 0, + firstFailedAt: 0, + })); + return; + } const delivery = yield* Ref.get(deliveryRef); const batch = delivery.failedBatch.length > 0 ? delivery.failedBatch : yield* takeBatch; if (batch.length === 0) { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 73d4a7fe4a336..4616e6aa8c570 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2320,15 +2320,21 @@ function ScopedProductUsageDataRow() { : undefined } control={ - value) : settings.telemetryEnabled - } - disabled={lockedEverywhere} - onCheckedChange={(checked) => updateSettings({ telemetryEnabled: Boolean(checked) })} - aria-label="Product usage data" - /> + 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" + /> + ) } /> ); From 88b59e4f5de16ce3a13b8b1ec9f7eef75900d90f Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Thu, 29 Oct 2026 20:28:44 +0100 Subject: [PATCH 03/15] fix(server,shared): treat telemetry as off when settings.json cannot be parsed A broken settings file made the server fall back to defaults, where telemetry is on, so an opt-out saved in that file was lost. Like `continueThreadsAfterServerUpdate`, the setting now falls back to off, both for usage events and for relay tracing read at startup. --- apps/server/src/serverSettings.test.ts | 1 + apps/server/src/serverSettings.ts | 8 ++++++-- docs/user/telemetry.md | 2 +- packages/shared/src/serverSettings.test.ts | 4 ++-- packages/shared/src/serverSettings.ts | 3 ++- 5 files changed, 12 insertions(+), 6 deletions(-) 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/docs/user/telemetry.md b/docs/user/telemetry.md index f9d6591e2be22..673141ac4394f 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -23,7 +23,7 @@ To disable product events and relay tracing for an environment, turn off **Produ **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. +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. Settings shows when the variable decides. Relay tracing also stops when `T3CODE_OTEL_SDK_DISABLED` or diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index b94f3e6b86881..7a70f54acbcdf 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -298,12 +298,12 @@ describe("serverSettings helpers", () => { ).toBe(false); }); - it("falls back cleanly when persisted settings are invalid", () => { + it("falls back to no endpoints and telemetry off when persisted settings are invalid", () => { expect(parsePersistedServerObservabilitySettings("{")).toEqual({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined, - telemetryEnabled: true, + telemetryEnabled: false, }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index e0b47ce34bcdf..56113b23ef489 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -213,7 +213,8 @@ export function parsePersistedServerObservabilitySettings( if (Option.isSome(decoded)) { return extractPersistedServerObservabilitySettings(decoded.value); } - return NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS; + // The file may hold a telemetry opt-out we can no longer read. + return { ...NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS, telemetryEnabled: false }; } function shouldReplaceTextGenerationModelSelection( From 8fee6fec281e27de9783b453b8e87a3156137fb5 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Thu, 29 Oct 2026 20:35:37 +0100 Subject: [PATCH 04/15] test(server): drive the telemetry CLI test through Effect FileSystem --- apps/server/src/cli/telemetry.test.ts | 50 +++++++++++++++------------ 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/apps/server/src/cli/telemetry.test.ts b/apps/server/src/cli/telemetry.test.ts index 44ef3b636222e..cde7d1a9bda13 100644 --- a/apps/server/src/cli/telemetry.test.ts +++ b/apps/server/src/cli/telemetry.test.ts @@ -1,14 +1,11 @@ -// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary. -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; -import { assert, describe, it } from "@effect/vitest"; +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"; @@ -28,26 +25,33 @@ const runCli = (args: ReadonlyArray, env: Record = {}) = ), ); -const makeBaseDir = () => - NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-telemetry-cli-")); - -const settingsPathFor = (baseDir: string) => NodePath.join(baseDir, "userdata", "settings.json"); +/** A fresh base dir and its settings.json path. */ +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 = (baseDir: string): Record => - JSON.parse(NodeFS.readFileSync(settingsPathFor(baseDir), "utf8")) as Record; +const readSettings = Effect.fn(function* (settingsPath: string) { + const fs = yield* FileSystem.FileSystem; + return JSON.parse(yield* fs.readFileString(settingsPath)) as Record; +}); -describe("t3 telemetry", () => { +it.layer(NodeServices.layer)("t3 telemetry", (it) => { it.effect("turns the setting off and back on, keeping other settings", () => Effect.gen(function* () { - const baseDir = makeBaseDir(); - NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); - NodeFS.writeFileSync( - settingsPathFor(baseDir), + 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(readSettings(baseDir), { + assert.deepEqual(yield* readSettings(settingsPath), { somethingFromANewerBuild: { nested: true }, telemetryEnabled: false, }); @@ -55,21 +59,23 @@ describe("t3 telemetry", () => { assert.isTrue(off.some((line) => String(line).startsWith("Telemetry setting: off"))); yield* runCli(["telemetry", "on", "--base-dir", baseDir]); - assert.deepEqual(readSettings(baseDir), { somethingFromANewerBuild: { nested: true } }); + 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("says when the variable in this shell overrides the setting", () => Effect.gen(function* () { - const baseDir = makeBaseDir(); + 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), ); }); From ac153e88f8ec9fbbb73a01a16c10585603b60843 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Thu, 29 Oct 2026 20:49:39 +0100 Subject: [PATCH 05/15] fix(desktop): treat an unreadable settings.json as telemetry off Only a missing file means nothing was saved. Any other read failure now falls back to telemetry off, like the server does, so an opt-out in a file the desktop app cannot read still holds. --- .../src/app/DesktopObservability.test.ts | 21 +++++++++++++++++++ apps/desktop/src/app/DesktopObservability.ts | 15 +++++++++---- apps/server/src/cli/telemetry.test.ts | 1 - 3 files changed, 32 insertions(+), 5 deletions(-) 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 ea3f59b833527..5f0e9f3430d4f 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -338,10 +338,17 @@ export const readPersistedObservabilitySettings: Effect.Effect< > = 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) - ? NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS - : 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/server/src/cli/telemetry.test.ts b/apps/server/src/cli/telemetry.test.ts index cde7d1a9bda13..cc1c08dded8b4 100644 --- a/apps/server/src/cli/telemetry.test.ts +++ b/apps/server/src/cli/telemetry.test.ts @@ -25,7 +25,6 @@ const runCli = (args: ReadonlyArray, env: Record = {}) = ), ); -/** A fresh base dir and its settings.json path. */ const makeBaseDir = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; From 87fe31d13269e64df7fde7867e5025f159bc0fbf Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Sun, 1 Nov 2026 12:14:39 +0100 Subject: [PATCH 06/15] test(server): the telemetry setting decides after desktop shell discovery Desktop launches still wait for the login shell before sending usage events. Once discovery finishes, a shell that sets no T3CODE_TELEMETRY_ENABLED leaves the decision to the setting. --- .../src/telemetry/AnalyticsService.test.ts | 20 +++++++++++++++++++ apps/server/src/telemetry/AnalyticsService.ts | 8 ++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 9ffc76432fd3c..2bbb1db21947f 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -221,6 +221,26 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService }), ); + it.effect( + "after desktop shell discovery, the setting decides when the shell sets no variable", + () => + 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; + }), + { 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( diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 5fd91dc61ba47..10a61fa0ef9df 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -131,18 +131,18 @@ export const make = Effect.gen(function* () { // consent from the current login shell. const variableEnabled = Effect.suspend((): Effect.Effect> => { if (!policyAllows || Option.contains(telemetryConfig.enabled, false)) - return Effect.succeed(Option.some(false)); + return Effect.succeedSome(false); if (!awaitingShellConsent) return Effect.succeed(telemetryConfig.enabled); if (hostEnvironment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING === "1") - return Effect.succeed(Option.some(false)); + return Effect.succeedSome(false); const preference = hostEnvironment.T3CODE_TELEMETRY_ENABLED; - if (preference === undefined) return Effect.succeed(Option.none()); + if (preference === undefined) return Effect.succeedNone; return Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe( Effect.provideService( ConfigProvider.ConfigProvider, ConfigProvider.fromEnv({ env: { T3CODE_TELEMETRY_ENABLED: preference } }), ), - Effect.map(Option.some), + Effect.asSome, Effect.orElseSucceed(() => Option.some(false)), ); }); From e37f19685945a7b924b02c1051e750500e128a0b Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Sun, 1 Nov 2026 12:26:26 +0100 Subject: [PATCH 07/15] fix(server): a late shell telemetry value can only opt out, and Settings sees it Desktop shell discovery sets T3CODE_TELEMETRY_ENABLED after clients were told the setting decides, so a shell `true` could keep usage events flowing after the switch was turned off. A late shell value now only opts out, and the config snapshot reports that opt-out from the live environment, so Settings locks the switch on the next load. --- .../src/telemetry/AnalyticsService.test.ts | 38 ++++++++++--------- apps/server/src/telemetry/AnalyticsService.ts | 33 ++++++++++------ apps/server/src/ws.ts | 11 +++++- 3 files changed, 51 insertions(+), 31 deletions(-) diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 2bbb1db21947f..a44638ca9aab3 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -221,24 +221,26 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService }), ); - it.effect( - "after desktop shell discovery, the setting decides when the shell sets no variable", - () => - 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; - }), - { variable: null, setting: false }, - ).pipe(Effect.provideService(HostProcess.Environment, env)); - assert.equal(batches.length, 0); - }), + 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", () => diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 10a61fa0ef9df..05fcfe0e37f33 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -117,6 +117,23 @@ 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* () { @@ -127,23 +144,17 @@ export const make = Effect.gen(function* () { const policyAllows = ManagedPolicy.deniedBy(yield* ManagedPolicy.ManagedPolicy, "telemetry") === undefined; // What decides over the setting, when anything does: the policy's opt-out, then the variable. - // A desktop launch waits for the login shell's value, since a cached PATH does not establish - // consent from the current login shell. + // 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.succeedSome(false); - const preference = hostEnvironment.T3CODE_TELEMETRY_ENABLED; - if (preference === undefined) return Effect.succeedNone; - return Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnv({ env: { T3CODE_TELEMETRY_ENABLED: preference } }), - ), - Effect.asSome, - Effect.orElseSucceed(() => Option.some(false)), + return environmentOptsOut(hostEnvironment).pipe( + Effect.map((optsOut) => (optsOut ? Option.some(false) : telemetryConfig.enabled)), ); }); const httpClient = yield* HttpClient.HttpClient; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ee728cfca66ef..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,9 +1870,9 @@ export const layerWsRpc = ( } : {}), otlpLogsEnabled: config.otlpLogsUrl !== undefined, - ...(config.telemetryEnabledByEnvironment === undefined + ...(telemetryEnabledByEnvironment === undefined ? {} - : { telemetryEnabledByEnvironment: config.telemetryEnabledByEnvironment }), + : { telemetryEnabledByEnvironment }), }, settings, ...(serverSettings.managedPolicy === undefined From 74c3d7c9c9ba03665be03d53adf1efde4ae217cf Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Sun, 1 Nov 2026 12:37:40 +0100 Subject: [PATCH 08/15] fix(server): discard queued usage events as soon as telemetry is turned off The queue was only cleared by the next flush, which runs every 10 seconds or later during retry backoff. Turning telemetry off and back on before then let events recorded before the opt-out go out. The service now clears the queue when a settings change turns it off. --- .../src/telemetry/AnalyticsService.test.ts | 44 ++++++++++++++++++- apps/server/src/telemetry/AnalyticsService.ts | 36 +++++++++++---- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index a44638ca9aab3..7d7ae387ba65b 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,6 +20,7 @@ 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"; @@ -114,6 +117,23 @@ const recordTwentyEvents = Effect.gen(function* () { } }); +/** `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; @@ -127,7 +147,7 @@ const analyticsLayerWith = (client: HttpClient.HttpClient, options: AnalyticsTes ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-retry-" }), ), Layer.provideMerge( - ServerSettings.layerTest( + layerSettingsWithChanges( options.setting === undefined ? {} : { telemetryEnabled: options.setting }, ), ), @@ -368,6 +388,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService }), ); + 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 during a flush stops the batches after it", () => Effect.gen(function* () { let settings: ServerSettings.ServerSettingsService["Service"] | undefined; diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 05fcfe0e37f33..d4da9a099cf40 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"; @@ -269,18 +270,22 @@ export const make = Effect.gen(function* () { return [nextBatch, current.slice(nextBatch.length)] as const; }); + // 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* () { while (true) { - // Checked per batch: nothing recorded before an opt-out is sent after it. if (!(yield* isEnabled)) { - yield* Ref.set(bufferRef, []); - yield* Ref.update(deliveryRef, (delivery) => ({ - ...delivery, - failedBatch: [], - batchAttempts: 0, - firstFailedAt: 0, - })); + yield* discardQueued; return; } const delivery = yield* Ref.get(deliveryRef); @@ -390,6 +395,21 @@ 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 + : discardQueued.pipe(flushLock.withPermit), + ), + ), + Effect.forkScoped, + ); + yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.andThen(flushWhenDue)), { disableYield: true, }).pipe(Effect.forkScoped); From da8581bfdfd38890c99f84e502461a87a0cc8783 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Sun, 1 Nov 2026 12:42:47 +0100 Subject: [PATCH 09/15] fix(server): an opt-out during a flush stops it even if telemetry is back on The opt-out handler waits for the flush lock before discarding, and the running flush only checked whether telemetry was on before each batch. Turning telemetry off and on during a send let the remaining batches go out. Opt-outs are now counted as they land, and a flush stops as soon as the count changes. --- .../src/telemetry/AnalyticsService.test.ts | 26 +++++++++++++++++++ apps/server/src/telemetry/AnalyticsService.ts | 11 ++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 7d7ae387ba65b..38d638cbf1544 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -410,6 +410,32 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService }), ); + 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; diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index d4da9a099cf40..27e288bf0f9f1 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -270,6 +270,8 @@ 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, []), @@ -283,8 +285,10 @@ export const make = Effect.gen(function* () { // 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) { - if (!(yield* isEnabled)) { + // 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; } @@ -404,7 +408,10 @@ export const make = Effect.gen(function* () { Effect.flatMap(variableEnabled, (variable) => Option.getOrElse(variable, () => settings.telemetryEnabled) ? Effect.void - : discardQueued.pipe(flushLock.withPermit), + : Effect.andThen( + Ref.update(optOutsRef, (count) => count + 1), + discardQueued.pipe(flushLock.withPermit), + ), ), ), Effect.forkScoped, From 807a01192a88ff8663d56cf562c38e39bcf93be4 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Sun, 1 Nov 2026 21:18:07 +0100 Subject: [PATCH 10/15] fix(web): lock the usage data switch off where a managed policy turns telemetry off A policy with `telemetry: false` stops usage events and relay traces whatever the setting says, so the switch now shows off, is inert, and gives the organization's reason, the way the variable already locks it. --- .../settings/SettingsPanels.logic.test.ts | 59 +++++++++++++++++++ .../settings/SettingsPanels.logic.ts | 36 +++++++++++ .../components/settings/SettingsPanels.tsx | 19 +++--- 3 files changed, 102 insertions(+), 12 deletions(-) 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 4616e6aa8c570..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, @@ -2299,13 +2300,10 @@ function ScopedProductUsageDataRow() { ) { return null; } - const overrides = connectedEnvironments.flatMap((target) => { - const value = target.serverConfig?.observability.telemetryEnabledByEnvironment; - return value === undefined ? [] : [{ label: target.label, value }]; - }); - // The variable wins over the setting, so where it decides the switch shows its value instead. + // 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 && overrides.length === connectedEnvironments.length; + connectedEnvironments.length > 0 && locks.length === connectedEnvironments.length; return ( 0 - ? overrides - .map(({ label, value }) => `Set by T3CODE_TELEMETRY_ENABLED=${value} on ${label}.`) - .join(" ") - : undefined + locks.length > 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)} + checked={locks.every(({ value }) => value)} disabled aria-label="Product usage data" /> From 69859e55c6b248913468aac847f666e040d282c7 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Sun, 1 Nov 2026 21:22:33 +0100 Subject: [PATCH 11/15] fix(server): a null relay tracing config keeps tracing off `relayLayer` resolved a config whenever `tracingConfig` was null or missing, so a caller could not pass null to turn tracing off. It now resolves one only when the argument is omitted. Also shortens a test helper comment that the repository lint rule flagged. --- apps/server/src/cloud/relayTracing.ts | 7 ++++--- apps/server/src/telemetry/AnalyticsService.test.ts | 8 +++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index c4fef4b8a4d79..58e0251aaaf89 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -24,12 +24,13 @@ export const relayLayer = ( const policy = yield* ManagedPolicy.ManagedPolicy; return RelayTracing.layer( ManagedPolicy.deniedBy(policy, "telemetry") === undefined - ? (tracingConfig ?? - resolveRelayClientTracingConfig( + ? tracingConfig === undefined + ? resolveRelayClientTracingConfig( process.env, undefined, config.telemetryEnabledInSettings, - )) + ) + : tracingConfig : null, { ...resource, diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 38d638cbf1544..5099c1b1c91c0 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -172,11 +172,9 @@ const analyticsLayerWith = (client: HttpClient.HttpClient, options: AnalyticsTes }; /** - * Runs `record` (20 events by default), then lets `minutes` of background - * flushes run against a client that answers every send with `respond`. - * `T3CODE_TELEMETRY_ENABLED` is `true` unless `variable` says otherwise; `null` leaves it unset. - * `policy` is the managed policy document, none by default. - * 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, From 74bb558b81398f8322cd91db7f0ca01fb94e555c Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Wed, 4 Nov 2026 00:56:05 +0100 Subject: [PATCH 12/15] fix(shared): read the telemetry setting through the narrowed settings decoder Reading only observability from settings.json left telemetryEnabled undefined, so the desktop app and CLI lost the opt-out. --- apps/server/src/cli/telemetry.test.ts | 2 ++ packages/shared/src/serverSettings.test.ts | 6 ++++++ packages/shared/src/serverSettings.ts | 7 +++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/telemetry.test.ts b/apps/server/src/cli/telemetry.test.ts index cc1c08dded8b4..8231418b7267c 100644 --- a/apps/server/src/cli/telemetry.test.ts +++ b/apps/server/src/cli/telemetry.test.ts @@ -1,3 +1,4 @@ +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"; @@ -18,6 +19,7 @@ const runCli = (args: ReadonlyArray, env: Record = {}) = Effect.provide( Layer.mergeAll( NodeServices.layer, + NodeCrypto.layer, NetService.layer, Layer.fresh(TestConsole.layer), ConfigProvider.layer(ConfigProvider.fromUnknown(env)), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 7a70f54acbcdf..07fd35979f8ae 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -296,6 +296,12 @@ describe("serverSettings helpers", () => { 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", () => { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 56113b23ef489..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, From e83dc629fcf8092ba5d872171c7cd4d9ceaca338 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Wed, 4 Nov 2026 00:59:20 +0100 Subject: [PATCH 13/15] fix(web): settings search hides product usage data on servers without the setting --- .../components/settings/settingsSearch.test.ts | 18 ++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++++- .../useAvailableSettingsSearchItems.ts | 7 +++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index bac40332a2770..03108677ac6be 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -148,6 +148,7 @@ describe("searchSettings", () => { "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 1618b1146f9f0..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> = { @@ -911,6 +913,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Product usage data", to: "/settings/about", scope: "environment", + telemetrySettingOnly: true, searchTerms: ["telemetry analytics usage data tracking opt out disable posthog traces"], }, { @@ -1523,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, }); } From 33034196cd96d300a3f2d8b2db2f91ef0450243f Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Wed, 4 Nov 2026 08:50:45 +0100 Subject: [PATCH 14/15] fix(server): t3 telemetry status reports a blank settings file as off --- apps/server/src/cli/settingsFile.ts | 8 +++++--- apps/server/src/cli/telemetry.test.ts | 15 +++++++++++++++ apps/server/src/cli/telemetry.ts | 8 ++++---- packages/shared/src/serverSettings.test.ts | 1 + 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/server/src/cli/settingsFile.ts b/apps/server/src/cli/settingsFile.ts index 52008a33c7b0a..1a37dffc467b9 100644 --- a/apps/server/src/cli/settingsFile.ts +++ b/apps/server/src/cli/settingsFile.ts @@ -84,23 +84,25 @@ export const resolveCliServerPaths = Effect.fn(function* (explicitBaseDir: Optio 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 { raw: "", settings: {} }; + 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 { raw, settings: {} }; + 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 { raw, settings }; + return { exists, raw, settings }; }); /** The server's in-process semaphore cannot serialize writes from this process. */ diff --git a/apps/server/src/cli/telemetry.test.ts b/apps/server/src/cli/telemetry.test.ts index 8231418b7267c..93c7f6a0159de 100644 --- a/apps/server/src/cli/telemetry.test.ts +++ b/apps/server/src/cli/telemetry.test.ts @@ -68,6 +68,21 @@ it.layer(NodeServices.layer)("t3 telemetry", (it) => { }).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; diff --git a/apps/server/src/cli/telemetry.ts b/apps/server/src/cli/telemetry.ts index fcd8cf7d3e1d5..9b066c19618dc 100644 --- a/apps/server/src/cli/telemetry.ts +++ b/apps/server/src/cli/telemetry.ts @@ -53,10 +53,10 @@ const telemetryStatusCommand = Command.make("status", { baseDir: baseDirFlag }). Command.withHandler((flags) => Effect.gen(function* () { const paths = yield* resolveCliServerPaths(flags.baseDir); - const { settings } = yield* readSettingsObject(paths.settingsPath); - yield* Console.log( - `Telemetry setting: ${settings.telemetryEnabled === false ? "off" : "on"} (${paths.settingsPath}).\n`, - ); + 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; }), ), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 07fd35979f8ae..f95499c413e5c 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -311,6 +311,7 @@ describe("serverSettings helpers", () => { otlpLogsUrl: undefined, telemetryEnabled: false, }); + expect(parsePersistedServerObservabilitySettings(" \n").telemetryEnabled).toBe(false); }); it("still deep merges text generation selection when only options are provided", () => { From 0ce269eaeb355e12867bb9c582f49074cde59371 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Wed, 4 Nov 2026 08:51:01 +0100 Subject: [PATCH 15/15] docs: a login shell value can only turn desktop telemetry off --- docs/user/telemetry.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md index 673141ac4394f..1fbd0fdc1c63d 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -26,13 +26,10 @@ relay tracing stops after the server restarts, and the desktop app's own relay t 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. Settings shows when the variable decides. Relay tracing also stops when `T3CODE_OTEL_SDK_DISABLED` or +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. -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. - 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 separate browser or mobile clients.