Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5488ba1
feat(server,web,desktop): turn telemetry off in Settings or with `t3 …
inayayousfi Oct 29, 2026
8a11a10
fix(server,web): stop a running flush on opt-out and keep the locked …
inayayousfi Oct 29, 2026
88b59e4
fix(server,shared): treat telemetry as off when settings.json cannot …
inayayousfi Oct 29, 2026
8fee6fe
test(server): drive the telemetry CLI test through Effect FileSystem
inayayousfi Oct 29, 2026
ac153e8
fix(desktop): treat an unreadable settings.json as telemetry off
inayayousfi Oct 29, 2026
87fe31d
test(server): the telemetry setting decides after desktop shell disco…
inayayousfi Nov 1, 2026
e37f196
fix(server): a late shell telemetry value can only opt out, and Setti…
inayayousfi Nov 1, 2026
74c3d7c
fix(server): discard queued usage events as soon as telemetry is turn…
inayayousfi Nov 1, 2026
da8581b
fix(server): an opt-out during a flush stops it even if telemetry is …
inayayousfi Nov 1, 2026
807a011
fix(web): lock the usage data switch off where a managed policy turns…
inayayousfi Nov 1, 2026
69859e5
fix(server): a null relay tracing config keeps tracing off
inayayousfi Nov 1, 2026
74bb558
fix(shared): read the telemetry setting through the narrowed settings…
inayayousfi Nov 3, 2026
e83dc62
fix(web): settings search hides product usage data on servers without…
inayayousfi Nov 3, 2026
3303419
fix(server): t3 telemetry status reports a blank settings file as off
inayayousfi Nov 4, 2026
0ce269e
docs: a login shell value can only turn desktop telemetry off
inayayousfi Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/app/DesktopObservability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 13 additions & 11 deletions apps/desktop/src/app/DesktopObservability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -330,23 +331,24 @@ export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(
} satisfies RotatingLogFileWriter;
});

const noPersistedObservabilitySettings: PersistedServerObservabilitySettings = {
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
otlpLogsUrl: undefined,
};

const readPersistedObservabilitySettings: Effect.Effect<
export const readPersistedObservabilitySettings: Effect.Effect<
PersistedServerObservabilitySettings,
never,
FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
> = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option);
return Option.isNone(raw)
? noPersistedObservabilitySettings
: parsePersistedServerObservabilitySettings(raw.value);
return yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(
Effect.map(parsePersistedServerObservabilitySettings),
// A missing file saved nothing; an unreadable one may hold a telemetry opt-out.
Effect.catch((cause) =>
Effect.succeed(
cause.reason._tag === "NotFound"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
? NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS
: { ...NO_PERSISTED_SERVER_OBSERVABILITY_SETTINGS, telemetryEnabled: false },
),
),
);
});

const resolveOtlpEndpoints = Effect.gen(function* () {
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ describe("installRelayTelemetryEnabled", () => {
const relayTelemetryEnabled = (
env: NodeJS.ProcessEnv,
policy?: Parameters<typeof DesktopManagedPolicy.layerTest>[0],
telemetrySetting = true,
) =>
Effect.gen(function* () {
const listeners = new Map<string, DesktopIpc.DesktopIpcSyncListener>();
Expand All @@ -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)),
Expand Down Expand Up @@ -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", () => {
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/binCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -109,6 +110,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>

servicePreflightCommand,
themeCommand,
telemetryCommand,
traceCommand,
doctorCommand,
policyCommand,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
15 changes: 13 additions & 2 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
),
Expand Down Expand Up @@ -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(() => ""));
Expand Down Expand Up @@ -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,
Expand Down
138 changes: 138 additions & 0 deletions apps/server/src/cli/settingsFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import * as ServerConfig from "../config.ts";
import { resolveBaseDir } from "../os-jank.ts";
import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";

const SparseSettings = Schema.Record(Schema.String, Schema.Unknown);
const decodeSettingsJson = Schema.decodeUnknownEffect(fromLenientJson(SparseSettings));
const encodeSettingsJson = Schema.encodeEffect(fromJsonStringPretty(SparseSettings));

/** The environment's settings.json as written: only the keys that differ from defaults. */
export type SparseSettings = typeof SparseSettings.Type;

export class SettingsUnreadableError extends Schema.TaggedError<SettingsUnreadableError>()(
"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>()(
"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>()(
"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>()(
"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<string>) {
const envHome = Option.filter(yield* envT3Home, (value) => value.trim().length > 0);
const configuredBaseDir = Option.orElse(explicitBaseDir, () => envHome);
const baseDir = yield* resolveBaseDir(Option.getOrUndefined(configuredBaseDir));
const userdataPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, {
baseDirIsExplicit: Option.isSome(configuredBaseDir),
});
if (
Option.isSome(configuredBaseDir) ||
(yield* hasLiveServer(userdataPaths.serverRuntimeStatePath))
) {
return userdataPaths;
}
const devPaths = yield* ServerConfig.deriveServerPaths(baseDir, DEV_STATE_PLACEHOLDER_URL);
return (yield* hasLiveServer(devPaths.serverRuntimeStatePath)) ? devPaths : userdataPaths;
});

const NO_SETTINGS: SparseSettings = {};

/** Propagate read errors to avoid overwriting unread settings with a sparse file. */
export const readSettingsObject = Effect.fn(function* (settingsPath: string) {
const fs = yield* FileSystem.FileSystem;
const exists = yield* fs
.exists(settingsPath)
.pipe(Effect.mapError((cause) => new SettingsUnreadableError({ settingsPath, cause })));
if (!exists) return { exists, raw: "", settings: NO_SETTINGS };

const raw = yield* fs
.readFileString(settingsPath)
.pipe(Effect.mapError((cause) => new SettingsUnreadableError({ settingsPath, cause })));
if (raw.trim().length === 0) return { exists, raw, settings: NO_SETTINGS };

const settings = yield* decodeSettingsJson(raw).pipe(
Effect.mapError((cause) => new SettingsMalformedError({ settingsPath, cause })),
);
return { exists, raw, settings };
});

/** The server's in-process semaphore cannot serialize writes from this process. */
const CONCURRENT_WRITE_ATTEMPTS = 5;

/**
* Rewrites settings.json with `update`, retrying when another writer changed it meanwhile.
* A running server picks the change up from its file watcher.
*/
export const updateSettingsObject = Effect.fn(function* (
settingsPath: string,
update: (settings: SparseSettings) => Effect.Effect<SparseSettings>,
) {
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;
}
});
Loading
Loading