From b961b578021137ca58233aaf23bbbfadb09242b5 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Thu, 11 Jun 2026 20:37:06 -0400 Subject: [PATCH] fix(server): Make telemetry opt in Default PostHog telemetry to disabled unless T3CODE_TELEMETRY_ENABLED=true or the server setting enables it. When disabled, use a no-op analytics service without creating a telemetry identifier. Add the settings toggle for analytics, keep analytics wired to the same ServerSettingsService instance as the settings API, stop flushing buffered telemetry if the setting is disabled mid-run, and preserve buffered events when identifier resolution is temporarily unavailable. --- apps/server/src/server.ts | 4 +- apps/server/src/serverSettings.test.ts | 111 ++++- apps/server/src/serverSettings.ts | 9 +- .../telemetry/Layers/AnalyticsService.test.ts | 469 +++++++++++++++++- .../src/telemetry/Layers/AnalyticsService.ts | 94 +++- .../settings/SettingsPanels.logic.test.ts | 32 ++ .../settings/SettingsPanels.logic.ts | 55 ++ .../components/settings/SettingsPanels.tsx | 58 ++- packages/contracts/src/settings.test.ts | 18 + packages/contracts/src/settings.ts | 4 + packages/shared/src/serverSettings.test.ts | 74 +++ packages/shared/src/serverSettings.ts | 30 ++ 12 files changed, 925 insertions(+), 33 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1da0ea27a65..37e92402f62 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -312,7 +312,6 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // no longer transitively provides it. Exposing it at the runtime level // keeps a single Live for all opencode consumers. Layer.provideMerge(OpenCodeRuntimeLive), - Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolverLive), @@ -328,11 +327,12 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ); const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( + Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(ServerSettingsLive), // Misc. Layer.provideMerge(ProcessDiagnostics.layer), Layer.provideMerge(ProcessResourceMonitor.layer), Layer.provideMerge(TraceDiagnostics.layer), - Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(ServerLifecycleEventsLive), Layer.provide(NetService.layer), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d24f2ee2826..15c959f2d0c 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ServerConfig } from "./config.ts"; import { ServerSettingsLive, ServerSettingsService } from "./serverSettings.ts"; @@ -427,6 +428,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const fileSystem = yield* FileSystem.FileSystem; const next = yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development", + automaticGitFetchInterval: Duration.seconds(10), observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -440,7 +442,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { serverPassword: "secret-password", }, }, - automaticGitFetchInterval: Duration.seconds(10), + telemetryEnabled: true, + telemetryPreferenceSet: true, }); assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); @@ -449,6 +452,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepEqual(JSON.parse(raw), { addProjectBaseDirectory: "~/Development", + automaticGitFetchInterval: 10_000, observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -462,11 +466,114 @@ it.layer(NodeServices.layer)("server settings", (it) => { serverPassword: "secret-password", }, }, - automaticGitFetchInterval: 10_000, + telemetryEnabled: true, + telemetryPreferenceSet: true, }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists explicit telemetry opt-out marker", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + yield* serverSettings.updateSettings({ + telemetryEnabled: false, + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepEqual(JSON.parse(raw), { + telemetryPreferenceSet: true, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("loads persisted telemetryEnabled as an explicit preference", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-server-settings-telemetry-", + }); + const settingsPath = path.join(baseDir, "userdata", "settings.json"); + yield* fileSystem.makeDirectory(path.dirname(settingsPath), { recursive: true }); + yield* fileSystem.writeFileString(settingsPath, '{ "telemetryEnabled": false }\n'); + + const layer = ServerSettingsLive.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + const settings = yield* Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.start; + return yield* serverSettings.getSettings; + }).pipe(Effect.provide(layer)); + + assert.deepInclude(settings, { + telemetryEnabled: false, + telemetryPreferenceSet: true, + }); + }), + ); + + it.effect("loads malformed persisted telemetryEnabled as an explicit preference", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-server-settings-telemetry-malformed-", + }); + const settingsPath = path.join(baseDir, "userdata", "settings.json"); + yield* fileSystem.makeDirectory(path.dirname(settingsPath), { recursive: true }); + yield* fileSystem.writeFileString(settingsPath, '{ "telemetryEnabled": "false" }\n'); + + const layer = ServerSettingsLive.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + const settings = yield* Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.start; + return yield* serverSettings.getSettings; + }).pipe(Effect.provide(layer)); + + assert.deepInclude(settings, { + telemetryEnabled: false, + telemetryPreferenceSet: true, + }); + }), + ); + + it.effect("loads persisted telemetry opt-in from schema-invalid settings", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-server-settings-telemetry-invalid-opt-in-", + }); + const settingsPath = path.join(baseDir, "userdata", "settings.json"); + yield* fileSystem.makeDirectory(path.dirname(settingsPath), { recursive: true }); + yield* fileSystem.writeFileString( + settingsPath, + '{ "telemetryEnabled": true, "providers": null }\n', + ); + + const layer = ServerSettingsLive.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + const settings = yield* Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.start; + return yield* serverSettings.getSettings; + }).pipe(Effect.provide(layer)); + + assert.deepInclude(settings, { + telemetryEnabled: true, + telemetryPreferenceSet: true, + }); + }), + ); + it.effect("stores sensitive provider instance environment values outside settings.json", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 6e1ceb16a8d..206c469bee2 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -48,7 +48,10 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; import { ServerConfig } from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; -import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { + applyServerSettingsPatch, + normalizeDecodedPersistedServerSettings, +} from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; const encodeServerSettings = Schema.encodeEffect(ServerSettings); @@ -305,9 +308,9 @@ const makeServerSettings = Effect.gen(function* () { path: settingsPath, issues: Cause.pretty(decoded.cause), }); - return DEFAULT_SERVER_SETTINGS; + return normalizeDecodedPersistedServerSettings(DEFAULT_SERVER_SETTINGS, raw); } - return decoded.value; + return normalizeDecodedPersistedServerSettings(decoded.value, raw); }); const settingsCache = yield* Cache.make({ diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index 5aa47406d9b..5ae59375342 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -1,14 +1,19 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import { DEFAULT_SERVER_SETTINGS, ServerSettingsError } from "@t3tools/contracts"; 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 Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { getTelemetryIdentifier } from "../Identify.ts"; import { AnalyticsService } from "../Services/AnalyticsService.ts"; import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts"; @@ -37,6 +42,222 @@ interface RecordedBatchBody { } it.layer(NodeServices.layer)("AnalyticsService test", (it) => { + it.effect("defaults to disabled without creating a telemetry identifier", () => + Effect.gen(function* () { + const capturedRequests: Array = []; + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-disabled-", + }); + const telemetryLayer = AnalyticsServiceLayerLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerSettingsService.layerTest()), + ); + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_POSTHOG_KEY: "phc_test_key", + T3CODE_POSTHOG_HOST: "", + }), + ); + const batchServerLayer = HttpServer.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const payload = yield* request.json.pipe( + Effect.map((body) => body as RecordedBatchRequest["body"]), + Effect.orElseSucceed(() => null), + ); + + capturedRequests.push({ path: request.url, body: payload }); + + return HttpServerResponse.jsonUnsafe({}); + }), + ); + const runtimeLayer = telemetryLayer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(NodeHttpServer.layerTest), + ); + + const anonymousIdExists = yield* Effect.gen(function* () { + yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); + const analytics = yield* AnalyticsService; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + yield* analytics.record("test.disabled"); + yield* analytics.flush; + + return yield* fileSystem.exists(serverConfig.anonymousIdPath); + }).pipe(Effect.provide(runtimeLayer)); + + assert.equal(capturedRequests.length, 0); + assert.equal(anonymousIdExists, false); + }), + ); + + it.effect("uses the server telemetry setting as an opt-in", () => + Effect.gen(function* () { + const capturedRequests: Array = []; + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-setting-", + }); + const telemetryLayer = AnalyticsServiceLayerLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerSettingsService.layerTest({ telemetryEnabled: true })), + ); + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_POSTHOG_KEY: "phc_test_key", + T3CODE_POSTHOG_HOST: "", + }), + ); + const batchServerLayer = HttpServer.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const payload = yield* request.json.pipe( + Effect.map((body) => body as RecordedBatchRequest["body"]), + Effect.orElseSucceed(() => null), + ); + + capturedRequests.push({ path: request.url, body: payload }); + + return HttpServerResponse.jsonUnsafe({}); + }), + ); + const runtimeLayer = telemetryLayer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(NodeHttpServer.layerTest), + ); + + yield* Effect.gen(function* () { + yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); + const analytics = yield* AnalyticsService; + + yield* analytics.record("test.setting.enabled"); + yield* analytics.flush; + }).pipe(Effect.provide(runtimeLayer)); + + const batchRequests = capturedRequests.filter( + (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => + Array.isArray(request.body?.batch), + ); + assert.equal(batchRequests.length, 1); + assert.equal(batchRequests[0]?.body.batch[0]?.event, "test.setting.enabled"); + }), + ); + + it.effect("seeds telemetry opt-in from the environment before any saved preference", () => + Effect.gen(function* () { + const capturedRequests: Array = []; + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-env-seed-", + }); + const telemetryLayer = AnalyticsServiceLayerLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerSettingsService.layerTest()), + ); + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_TELEMETRY_ENABLED: true, + T3CODE_POSTHOG_KEY: "phc_test_key", + T3CODE_POSTHOG_HOST: "", + }), + ); + const batchServerLayer = HttpServer.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const payload = yield* request.json.pipe( + Effect.map((body) => body as RecordedBatchRequest["body"]), + Effect.orElseSucceed(() => null), + ); + + capturedRequests.push({ path: request.url, body: payload }); + + return HttpServerResponse.jsonUnsafe({}); + }), + ); + const runtimeLayer = telemetryLayer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(NodeHttpServer.layerTest), + ); + + yield* Effect.gen(function* () { + yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); + const analytics = yield* AnalyticsService; + const serverSettings = yield* ServerSettingsService; + + assert.deepInclude(yield* serverSettings.getSettings, { + telemetryEnabled: true, + telemetryPreferenceSet: true, + }); + yield* analytics.record("test.env-seeded.enabled"); + yield* analytics.flush; + }).pipe(Effect.provide(runtimeLayer)); + + const batchRequests = capturedRequests.filter( + (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => + Array.isArray(request.body?.batch), + ); + assert.equal(batchRequests.length, 1); + assert.equal(batchRequests[0]?.body.batch[0]?.event, "test.env-seeded.enabled"); + }), + ); + + it.effect("does not let the environment override an explicit telemetry opt-out", () => + Effect.gen(function* () { + const capturedRequests: Array = []; + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-env-explicit-opt-out-", + }); + const telemetryLayer = AnalyticsServiceLayerLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge( + ServerSettingsService.layerTest({ + telemetryEnabled: false, + telemetryPreferenceSet: true, + }), + ), + ); + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_TELEMETRY_ENABLED: true, + T3CODE_POSTHOG_KEY: "phc_test_key", + T3CODE_POSTHOG_HOST: "", + }), + ); + const batchServerLayer = HttpServer.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const payload = yield* request.json.pipe( + Effect.map((body) => body as RecordedBatchRequest["body"]), + Effect.orElseSucceed(() => null), + ); + + capturedRequests.push({ path: request.url, body: payload }); + + return HttpServerResponse.jsonUnsafe({}); + }), + ); + const runtimeLayer = telemetryLayer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(NodeHttpServer.layerTest), + ); + + yield* Effect.gen(function* () { + yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); + const analytics = yield* AnalyticsService; + const serverSettings = yield* ServerSettingsService; + + assert.deepInclude(yield* serverSettings.getSettings, { + telemetryEnabled: false, + telemetryPreferenceSet: true, + }); + yield* analytics.record("test.env-opt-out.disabled"); + yield* analytics.flush; + }).pipe(Effect.provide(runtimeLayer)); + + assert.equal(capturedRequests.length, 0); + }), + ); + it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const capturedRequests: Array = []; @@ -44,10 +265,12 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { prefix: "t3-telemetry-base-", }); - const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer)); + const telemetryLayer = AnalyticsServiceLayerLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerSettingsService.layerTest({ telemetryEnabled: true })), + ); const configLayer = ConfigProvider.layer( ConfigProvider.fromUnknown({ - T3CODE_TELEMETRY_ENABLED: true, T3CODE_POSTHOG_KEY: "phc_test_key", T3CODE_POSTHOG_HOST: "", T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20, @@ -118,4 +341,246 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { ); }), ); + + it.effect("stops flushing buffered batches after telemetry is disabled mid-flush", () => + Effect.gen(function* () { + const capturedRequests: Array = []; + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-disable-mid-flush-", + }); + + const telemetryLayer = AnalyticsServiceLayerLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerSettingsService.layerTest({ telemetryEnabled: true })), + ); + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_POSTHOG_KEY: "phc_test_key", + T3CODE_POSTHOG_HOST: "", + T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20, + }), + ); + const batchServerLayer = HttpServer.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.method !== "POST") { + return HttpServerResponse.empty({ status: 404 }); + } + + const payload = yield* request.json.pipe( + Effect.map((body) => body as RecordedBatchRequest["body"]), + Effect.orElseSucceed(() => null), + ); + + capturedRequests.push({ path: request.url, body: payload }); + + if (capturedRequests.length === 1) { + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.updateSettings({ telemetryEnabled: false }); + } + + return HttpServerResponse.jsonUnsafe({}); + }), + ); + const runtimeLayer = telemetryLayer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(NodeHttpServer.layerTest), + ); + + yield* Effect.gen(function* () { + yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); + const analytics = yield* AnalyticsService; + + for (let index = 0; index < 45; index += 1) { + yield* analytics.record("test.flush.mid-disable", { index }); + } + + yield* analytics.flush; + yield* analytics.flush; + }).pipe(Effect.provide(runtimeLayer)); + + const batchRequests = capturedRequests.filter( + (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => + Array.isArray(request.body?.batch), + ); + assert.equal(batchRequests.length, 1); + const deliveredIndexes = batchRequests.flatMap((request) => + request.body.batch + .filter((event) => event.event === "test.flush.mid-disable") + .map((event) => event.properties?.index) + .filter((index): index is number => typeof index === "number"), + ); + + assert.deepEqual( + deliveredIndexes.toSorted((a, b) => a - b), + Array.from({ length: 20 }, (_, index) => index), + ); + }), + ); + + it.effect("retains buffered events when telemetry identifier is unavailable", () => + Effect.gen(function* () { + const capturedRequests: Array = []; + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-missing-identifier-", + }); + + const telemetryLayer = AnalyticsServiceLayerLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerSettingsService.layerTest({ telemetryEnabled: true })), + ); + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_POSTHOG_KEY: "phc_test_key", + T3CODE_POSTHOG_HOST: "", + T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20, + }), + ); + const batchServerLayer = HttpServer.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.method !== "POST") { + return HttpServerResponse.empty({ status: 404 }); + } + + const payload = yield* request.json.pipe( + Effect.map((body) => body as RecordedBatchRequest["body"]), + Effect.orElseSucceed(() => null), + ); + + capturedRequests.push({ path: request.url, body: payload }); + + return HttpServerResponse.jsonUnsafe({}); + }), + ); + const runtimeLayer = telemetryLayer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(NodeHttpServer.layerTest), + ); + + yield* Effect.gen(function* () { + yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + const emptyHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-telemetry-empty-home-", + }); + const originalHome = process.env.HOME; + process.env.HOME = emptyHome; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + }), + ); + yield* fileSystem.makeDirectory(serverConfig.anonymousIdPath); + const analytics = yield* AnalyticsService; + + yield* analytics.record("test.flush.identifier-unavailable", { index: 0 }); + yield* analytics.flush; + assert.equal(capturedRequests.length, 0); + + yield* fileSystem.remove(serverConfig.anonymousIdPath, { recursive: true, force: true }); + yield* analytics.flush; + }).pipe(Effect.provide(runtimeLayer)); + + const batchRequests = capturedRequests.filter( + (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => + Array.isArray(request.body?.batch), + ); + assert.equal(batchRequests.length, 1); + assert.equal(batchRequests[0]?.body.batch[0]?.event, "test.flush.identifier-unavailable"); + assert.equal(batchRequests[0]?.body.batch[0]?.properties?.index, 0); + }), + ); + + it.effect("retains a dequeued batch when telemetry setting read fails mid-flush", () => + Effect.gen(function* () { + const capturedRequests: Array = []; + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-settings-read-failure-", + }); + const remainingSuccessfulSettingsReads = yield* Ref.make(1); + const settingsLayer = Layer.succeed(ServerSettingsService, { + start: Effect.void, + ready: Effect.void, + getSettings: Effect.gen(function* () { + const remaining = yield* Ref.get(remainingSuccessfulSettingsReads); + if (remaining <= 0) { + return yield* new ServerSettingsError({ + settingsPath: "", + detail: "Mock settings read failure", + }); + } + yield* Ref.set(remainingSuccessfulSettingsReads, remaining - 1); + return { + ...DEFAULT_SERVER_SETTINGS, + telemetryEnabled: true, + }; + }), + updateSettings: () => + Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + telemetryEnabled: true, + }), + streamChanges: Stream.empty, + }); + + const telemetryLayer = AnalyticsServiceLayerLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(settingsLayer), + ); + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_POSTHOG_KEY: "phc_test_key", + T3CODE_POSTHOG_HOST: "", + T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20, + }), + ); + const batchServerLayer = HttpServer.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.method !== "POST") { + return HttpServerResponse.empty({ status: 404 }); + } + + const payload = yield* request.json.pipe( + Effect.map((body) => body as RecordedBatchRequest["body"]), + Effect.orElseSucceed(() => null), + ); + + capturedRequests.push({ path: request.url, body: payload }); + + return HttpServerResponse.jsonUnsafe({}); + }), + ); + const runtimeLayer = telemetryLayer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(NodeHttpServer.layerTest), + ); + + yield* Effect.gen(function* () { + yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); + const analytics = yield* AnalyticsService; + + yield* analytics.record("test.flush.settings-read-failure", { index: 0 }); + yield* analytics.flush; + assert.equal(capturedRequests.length, 0); + + yield* Ref.set(remainingSuccessfulSettingsReads, 2); + yield* analytics.flush; + }).pipe(Effect.provide(runtimeLayer)); + + const batchRequests = capturedRequests.filter( + (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => + Array.isArray(request.body?.batch), + ); + assert.equal(batchRequests.length, 1); + assert.equal(batchRequests[0]?.body.batch[0]?.event, "test.flush.settings-read-failure"); + assert.equal(batchRequests[0]?.body.batch[0]?.properties?.index, 0); + }), + ); }); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 0d51d7c66b1..c9590546ac9 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -1,22 +1,28 @@ /** - * AnalyticsServiceLive - Anonymous PostHog telemetry layer. + * AnalyticsServiceLive - Opt-in anonymous PostHog telemetry layer. * - * Persists a random installation-scoped anonymous id to state dir, buffers - * events in memory, and flushes batches to PostHog over Effect HttpClient. + * When enabled, persists a random installation-scoped anonymous id to state dir, + * buffers events in memory, and flushes batches to PostHog over Effect + * HttpClient. * * @module AnalyticsServiceLive */ import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; import { getTelemetryIdentifier } from "../Identify.ts"; import packageJson from "../../../package.json" with { type: "json" }; @@ -27,6 +33,12 @@ interface BufferedAnalyticsEvent { readonly capturedAt: string; } +class TelemetryIdentifierUnavailableError extends Data.TaggedError( + "TelemetryIdentifierUnavailableError", +)<{ + readonly message: string; +}> {} + const TelemetryEnvConfig = Config.all({ posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe( Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), @@ -34,7 +46,7 @@ const TelemetryEnvConfig = Config.all({ posthogHost: Config.string("T3CODE_POSTHOG_HOST").pipe( Config.withDefault("https://us.i.posthog.com"), ), - enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)), + enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(false)), 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), @@ -44,14 +56,51 @@ const TelemetryEnvConfig = Config.all({ const makeAnalyticsService = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; + const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig; - const identifier = yield* getTelemetryIdentifier; + const serverSettings = yield* ServerSettingsService; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const bufferRef = yield* Ref.make>([]); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; + yield* serverSettings.start.pipe( + Effect.catch((cause) => + Effect.logDebug("Failed to start telemetry settings watcher", { cause }), + ), + ); + + if (telemetryConfig.enabled) { + yield* serverSettings.getSettings.pipe( + Effect.flatMap((settings) => + settings.telemetryPreferenceSet || settings.telemetryEnabled + ? Effect.void + : serverSettings + .updateSettings({ telemetryEnabled: true, telemetryPreferenceSet: true }) + .pipe(Effect.asVoid), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to seed telemetry setting from environment", { cause }), + ), + ); + } + + const isTelemetryEnabled = Effect.fn("isTelemetryEnabled")(function* () { + return yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.telemetryEnabled), + ); + }); + const resolveTelemetryIdentifier = getTelemetryIdentifier.pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig, serverConfig), + ); + const enqueueBufferedEvent = (event: string, properties?: Readonly>) => Effect.flatMap(DateTime.now, (now) => Ref.modify(bufferRef, (current) => { @@ -82,7 +131,14 @@ const makeAnalyticsService = Effect.gen(function* () { const sendBatch = Effect.fn("sendBatch")(function* ( events: ReadonlyArray, ) { - if (!telemetryConfig.enabled || !identifier) return; + const identifier = yield* resolveTelemetryIdentifier; + if (!identifier) { + return yield* Effect.fail( + new TelemetryIdentifierUnavailableError({ + message: "No telemetry identifier available", + }), + ); + } const payload = { api_key: telemetryConfig.posthogKey, @@ -111,6 +167,11 @@ const makeAnalyticsService = Effect.gen(function* () { const flush: AnalyticsServiceShape["flush"] = Effect.gen(function* () { while (true) { + if (!(yield* isTelemetryEnabled())) { + yield* Ref.set(bufferRef, []); + return; + } + const batch = yield* Ref.modify(bufferRef, (current) => { if (current.length === 0) { return [[] as ReadonlyArray, current] as const; @@ -124,6 +185,18 @@ const makeAnalyticsService = Effect.gen(function* () { return; } + const telemetryEnabledAfterDequeue = yield* isTelemetryEnabled().pipe( + Effect.catch((error) => + Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ); + if (!telemetryEnabledAfterDequeue) { + yield* Ref.set(bufferRef, []); + return; + } + yield* sendBatch(batch).pipe( Effect.catch((error) => Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( @@ -132,11 +205,16 @@ const makeAnalyticsService = Effect.gen(function* () { ), ); } - }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); + }).pipe(Effect.catch((cause) => Effect.logDebug("Failed to flush telemetry", { cause }))); const record: AnalyticsServiceShape["record"] = Effect.fn("record")( function* (event, properties) { - if (!telemetryConfig.enabled || !identifier) return; + const telemetryEnabled = yield* isTelemetryEnabled().pipe( + Effect.catch((cause) => + Effect.logDebug("Failed to read telemetry setting", { cause }).pipe(Effect.as(false)), + ), + ); + if (!telemetryEnabled) return; const enqueueResult = yield* enqueueBufferedEvent(event, properties); if (enqueueResult.dropped) { diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index d783d16c7ad..2969827b05c 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, ProviderDriverKind, ProviderInstanceId, type ProviderInstanceConfig, @@ -7,6 +8,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { buildProviderInstanceUpdatePatch, + buildRestoreDefaultsPatch, formatDiagnosticsDescription, } from "./SettingsPanels.logic"; @@ -102,3 +104,33 @@ describe("buildProviderInstanceUpdatePatch", () => { expect(patch.providers).toBeUndefined(); }); }); + +describe("buildRestoreDefaultsPatch", () => { + it("does not include telemetry when restoring unrelated settings", () => { + const patch = buildRestoreDefaultsPatch({ + settings: { + ...DEFAULT_UNIFIED_SETTINGS, + timestampFormat: "12-hour", + }, + isGitWritingModelDirty: false, + }); + + expect(patch).toEqual({ + timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + }); + }); + + it("includes telemetry when telemetry itself is restored", () => { + const patch = buildRestoreDefaultsPatch({ + settings: { + ...DEFAULT_UNIFIED_SETTINGS, + telemetryEnabled: true, + }, + isGitWritingModelDirty: false, + }); + + expect(patch).toEqual({ + telemetryEnabled: false, + }); + }); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 99d7052965a..7cf68243366 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -6,6 +6,7 @@ import type { UnifiedSettings, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import * as Duration from "effect/Duration"; function collapseOtelSignalsUrl(input: { readonly tracesUrl: string; @@ -89,3 +90,57 @@ export function buildProviderInstanceUpdatePatch(input: { : {}), }; } + +export function buildRestoreDefaultsPatch(input: { + readonly settings: UnifiedSettings; + readonly isGitWritingModelDirty: boolean; +}): Partial { + return { + ...(input.settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat + ? { timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat } + : {}), + ...(input.settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap + ? { diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap } + : {}), + ...(input.settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace + ? { diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace } + : {}), + ...(input.settings.sidebarThreadPreviewCount !== + DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount + ? { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount } + : {}), + ...(input.settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar + ? { autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar } + : {}), + ...(input.settings.enableAssistantStreaming !== + DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming + ? { enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming } + : {}), + ...(input.settings.telemetryEnabled !== DEFAULT_UNIFIED_SETTINGS.telemetryEnabled + ? { telemetryEnabled: DEFAULT_UNIFIED_SETTINGS.telemetryEnabled } + : {}), + ...(Duration.toMillis(input.settings.automaticGitFetchInterval) !== + Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval) + ? { automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval } + : {}), + ...(input.settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode + ? { defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode } + : {}), + ...(input.settings.newWorktreesStartFromOrigin !== + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin + ? { newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin } + : {}), + ...(input.settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory + ? { addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory } + : {}), + ...(input.settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive + ? { confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive } + : {}), + ...(input.settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete + ? { confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete } + : {}), + ...(input.isGitWritingModelDirty + ? { textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection } + : {}), + }; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 20ebafbce40..452a2110a95 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -76,6 +76,7 @@ import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, + buildRestoreDefaultsPatch, formatDiagnosticsDescription, } from "./SettingsPanels.logic"; import { @@ -423,6 +424,9 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Delete confirmation"] : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(settings.telemetryEnabled !== DEFAULT_UNIFIED_SETTINGS.telemetryEnabled + ? ["Telemetry"] + : []), ], [ isGitWritingModelDirty, @@ -436,6 +440,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.diffWordWrap, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, + settings.telemetryEnabled, settings.sidebarThreadPreviewCount, settings.timestampFormat, theme, @@ -453,23 +458,16 @@ export function useSettingsRestore(onRestored?: () => void) { if (!confirmed) return; setTheme("system"); - updateSettings({ - timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, - diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, - diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, - sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, - enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, - automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, - confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, - confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, - textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }); + updateSettings(buildRestoreDefaultsPatch({ settings, isGitWritingModelDirty })); onRestored?.(); - }, [changedSettingLabels, onRestored, setTheme, updateSettings]); + }, [ + changedSettingLabels, + isGitWritingModelDirty, + onRestored, + setTheme, + settings, + updateSettings, + ]); return { changedSettingLabels, @@ -971,6 +969,34 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + telemetryEnabled: DEFAULT_UNIFIED_SETTINGS.telemetryEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + telemetryEnabled: Boolean(checked), + telemetryPreferenceSet: true, + }) + } + aria-label="Share anonymous telemetry" + /> + } + /> ); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index aba97cbe205..a5cf647dcfb 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -8,6 +8,15 @@ const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +describe("ServerSettings.telemetryEnabled", () => { + it("defaults telemetry to disabled for legacy settings files", () => { + expect(DEFAULT_SERVER_SETTINGS.telemetryEnabled).toBe(false); + expect(DEFAULT_SERVER_SETTINGS.telemetryPreferenceSet).toBe(false); + expect(decodeServerSettings({}).telemetryEnabled).toBe(false); + expect(decodeServerSettings({}).telemetryPreferenceSet).toBe(false); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); @@ -106,6 +115,15 @@ describe("ServerSettingsPatch.providerInstances", () => { }); }); +describe("ServerSettingsPatch.telemetryEnabled", () => { + it("decodes telemetry opt-in patches", () => { + expect(decodeServerSettingsPatch({ telemetryEnabled: true }).telemetryEnabled).toBe(true); + expect(decodeServerSettingsPatch({ telemetryPreferenceSet: true }).telemetryPreferenceSet).toBe( + true, + ); + }); +}); + describe("ServerSettingsPatch string normalization", () => { it("trims string settings while decoding patches", () => { const patch = decodeServerSettingsPatch({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1cb57a98254..640fab00b74 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -366,6 +366,8 @@ export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + telemetryEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + telemetryPreferenceSet: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), automaticGitFetchInterval: Schema.DurationFromMillis.pipe( Schema.withDecodingDefault( Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), @@ -484,6 +486,8 @@ export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), + telemetryEnabled: Schema.optionalKey(Schema.Boolean), + telemetryPreferenceSet: Schema.optionalKey(Schema.Boolean), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 5bec7d386b6..0765d0a25f5 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -9,6 +9,7 @@ import { applyServerSettingsPatch, extractPersistedServerObservabilitySettings, normalizePersistedServerSettingString, + normalizeDecodedPersistedServerSettings, parsePersistedServerObservabilitySettings, } from "./serverSettings.ts"; @@ -161,6 +162,79 @@ describe("serverSettings helpers", () => { }); }); + it("marks telemetry preference set when telemetry is patched", () => { + expect( + applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + telemetryEnabled: false, + }), + ).toMatchObject({ + telemetryEnabled: false, + telemetryPreferenceSet: true, + }); + + expect( + applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + telemetryEnabled: true, + telemetryPreferenceSet: false, + }), + ).toMatchObject({ + telemetryEnabled: true, + telemetryPreferenceSet: true, + }); + }); + + it("keeps telemetry preference sticky once set", () => { + expect( + applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + telemetryEnabled: false, + telemetryPreferenceSet: true, + }, + { + telemetryPreferenceSet: false, + }, + ), + ).toMatchObject({ + telemetryEnabled: false, + telemetryPreferenceSet: true, + }); + }); + + it("treats persisted telemetryEnabled as an explicit preference", () => { + expect( + normalizeDecodedPersistedServerSettings( + { ...DEFAULT_SERVER_SETTINGS, telemetryEnabled: false, telemetryPreferenceSet: false }, + '{ "telemetryEnabled": false }', + ), + ).toMatchObject({ + telemetryEnabled: false, + telemetryPreferenceSet: true, + }); + + expect( + normalizeDecodedPersistedServerSettings( + { ...DEFAULT_SERVER_SETTINGS, telemetryEnabled: false, telemetryPreferenceSet: false }, + '{ "telemetryEnabled": true }', + ), + ).toMatchObject({ + telemetryEnabled: true, + telemetryPreferenceSet: true, + }); + }); + + it("treats malformed persisted telemetryEnabled as an explicit preference", () => { + expect( + normalizeDecodedPersistedServerSettings( + { ...DEFAULT_SERVER_SETTINGS, telemetryEnabled: false, telemetryPreferenceSet: false }, + '{ "telemetryEnabled": "false" }', + ), + ).toMatchObject({ + telemetryEnabled: false, + telemetryPreferenceSet: true, + }); + }); + it("replaces providerInstances maps so omitted instance fields are cleared", () => { const codexId = ProviderInstanceId.make("codex"); const current = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 1bbf466f60b..a6f935a4f42 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -7,6 +7,8 @@ import { createModelSelection } from "./model.ts"; const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); +const UnknownJson = fromLenientJson(Schema.Unknown); +const decodeUnknownJson = Schema.decodeUnknownOption(UnknownJson); export interface PersistedServerObservabilitySettings { readonly otlpTracesUrl: string | undefined; @@ -42,6 +44,29 @@ export function parsePersistedServerObservabilitySettings( return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } +export function normalizeDecodedPersistedServerSettings( + settings: ServerSettings, + raw: string, +): ServerSettings { + const decodedRaw = decodeUnknownJson(raw); + if ( + Option.isSome(decodedRaw) && + decodedRaw.value !== null && + typeof decodedRaw.value === "object" && + Object.prototype.hasOwnProperty.call(decodedRaw.value, "telemetryEnabled") + ) { + const rawTelemetryEnabled = (decodedRaw.value as Record).telemetryEnabled; + return { + ...settings, + ...(typeof rawTelemetryEnabled === "boolean" + ? { telemetryEnabled: rawTelemetryEnabled } + : {}), + telemetryPreferenceSet: true, + }; + } + return settings; +} + function shouldReplaceTextGenerationModelSelection( patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined, ): boolean { @@ -80,6 +105,11 @@ export function applyServerSettingsPatch( const next = deepMerge(current, patchForMerge); const nextWithReplacements = { ...next, + ...(current.telemetryPreferenceSet || + next.telemetryPreferenceSet || + patch.telemetryEnabled !== undefined + ? { telemetryPreferenceSet: true } + : {}), ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}),