diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5c713ff2be7..e72603032a9 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -34,8 +34,13 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import * as ServerSettingsModule from "./serverSettings.ts"; -const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const CliRuntimeLayer = Layer.mergeAll( + NodeServices.layer, + NetService.layer, + ServerSettingsModule.ServerSettingsService.layerTest(), +); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index ddfbf5e3ecc..ff6a4c88f88 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -13,8 +13,13 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; +import * as ServerSettingsModule from "./serverSettings.ts"; -const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const CliRuntimeLayer = Layer.mergeAll( + NodeServices.layer, + NetService.layer, + ServerSettingsModule.ServerSettingsService.layerTest(), +); const connectPublicConfigMissingMessage = "T3 Connect commands are unavailable: this build is missing T3 Connect public configuration."; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18de..6f747545f1e 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 PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -486,6 +487,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", @@ -499,7 +501,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"); @@ -508,6 +511,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", @@ -521,11 +525,117 @@ 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* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.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 = ServerSettingsModule.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + const settings = yield* Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.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 = ServerSettingsModule.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + const settings = yield* Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.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 = ServerSettingsModule.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + const settings = yield* Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.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* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4119a72640f..e8e0c3ca5c9 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -47,7 +47,10 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as 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); @@ -306,9 +309,9 @@ const make = Effect.gen(function* () { issues: Cause.pretty(decoded.cause), cause: 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/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index d69bab32feb..73903046aa1 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/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 * as ServerConfig from "../config.ts"; +import * as ServerSettingsModule from "../serverSettings.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; import * as AnalyticsService from "./AnalyticsService.ts"; @@ -36,6 +41,276 @@ 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 = AnalyticsService.layer.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerSettingsModule.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.AnalyticsService; + const serverConfig = yield* ServerConfig.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 = AnalyticsService.layer.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge( + ServerSettingsModule.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.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 = AnalyticsService.layer.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerSettingsModule.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.AnalyticsService; + const serverSettings = yield* ServerSettingsModule.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("honors an explicit environment telemetry opt-out over persisted opt-in", () => + Effect.gen(function* () { + const capturedRequests: Array = []; + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-env-hard-opt-out-", + }); + const telemetryLayer = AnalyticsService.layer.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge( + ServerSettingsModule.ServerSettingsService.layerTest({ + telemetryEnabled: true, + telemetryPreferenceSet: true, + }), + ), + ); + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_TELEMETRY_ENABLED: false, + 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.AnalyticsService; + + yield* analytics.record("test.env-hard-opt-out.disabled"); + yield* analytics.flush; + }).pipe(Effect.provide(runtimeLayer)); + + assert.equal(capturedRequests.length, 0); + }), + ); + + 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 = AnalyticsService.layer.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge( + ServerSettingsModule.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.AnalyticsService; + const serverSettings = yield* ServerSettingsModule.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 = []; @@ -43,10 +318,14 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { prefix: "t3-telemetry-base-", }); - const telemetryLayer = AnalyticsService.layer.pipe(Layer.provideMerge(serverConfigLayer)); + const telemetryLayer = AnalyticsService.layer.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge( + ServerSettingsModule.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, @@ -117,4 +396,251 @@ 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 = AnalyticsService.layer.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge( + ServerSettingsModule.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* ServerSettingsModule.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.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 = AnalyticsService.layer.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge( + ServerSettingsModule.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.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.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(3); + const settingsLayer = Layer.succeed(ServerSettingsModule.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: "", + operation: "read-file", + cause: "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 = AnalyticsService.layer.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.AnalyticsService; + + yield* analytics.record("test.flush.settings-read-failure", { index: 0 }); + yield* analytics.flush; + assert.equal(capturedRequests.length, 0); + + yield* Ref.set(remainingSuccessfulSettingsReads, 4); + 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/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 5fdc7bdeb19..01c102ed506 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,18 +1,21 @@ /** - * Anonymous PostHog telemetry service. + * Opt-in anonymous PostHog telemetry service. * - * Persists an installation-scoped anonymous identifier, buffers events in - * memory, and flushes batches over Effect's HTTP client. + * When enabled, persists an installation-scoped anonymous identifier, buffers + * events in memory, and flushes batches over Effect's HTTP client. * * @module AnalyticsService */ import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; 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 * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -20,6 +23,7 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; +import * as ServerSettingsModule from "../serverSettings.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; interface BufferedAnalyticsEvent { @@ -35,7 +39,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)), + telemetryEnvEnabled: 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), @@ -68,14 +72,58 @@ export class AnalyticsService extends Context.Service< export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; + const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig.ServerConfig; - const identifier = yield* getTelemetryIdentifier; + const serverSettings = yield* ServerSettingsModule.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 }), + ), + ); + + const telemetryEnvEnabled = telemetryConfig.telemetryEnvEnabled; + const telemetryEnvExplicitlyDisabled = + Option.isSome(telemetryEnvEnabled) && telemetryEnvEnabled.value === false; + + if (Option.isSome(telemetryEnvEnabled) && telemetryEnvEnabled.value === true) { + 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* () { + if (telemetryEnvExplicitlyDisabled) { + return false; + } + 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, serverConfig), + ); + const enqueueBufferedEvent = (event: string, properties?: Readonly>) => Effect.flatMap(DateTime.now, (now) => Ref.modify(bufferRef, (current) => { @@ -106,7 +154,15 @@ export const make = Effect.gen(function* () { const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* ( events: ReadonlyArray, ) { - if (!telemetryConfig.enabled || !identifier) return; + if (!(yield* isTelemetryEnabled())) { + return; + } + + const identifier = yield* resolveTelemetryIdentifier; + if (!identifier) { + yield* Effect.logDebug("Skipping telemetry batch; identifier unavailable"); + return; + } const payload = { api_key: telemetryConfig.posthogKey, @@ -135,6 +191,16 @@ export const make = Effect.gen(function* () { const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { while (true) { + if (!(yield* isTelemetryEnabled())) { + yield* Ref.set(bufferRef, []); + return; + } + + const bufferedEvents = yield* Ref.get(bufferRef); + if (bufferedEvents.length === 0) { + return; + } + const batch = yield* Ref.modify(bufferRef, (current) => { if (current.length === 0) { return [[] as ReadonlyArray, current] as const; @@ -148,6 +214,25 @@ export const make = 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; + } + + const identifier = yield* resolveTelemetryIdentifier; + if (!identifier) { + yield* Ref.update(bufferRef, (current) => [...batch, ...current]); + yield* Effect.logDebug("Deferring telemetry flush; identifier unavailable"); + return; + } + yield* sendBatch(batch).pipe( Effect.catch((error) => Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( @@ -156,11 +241,16 @@ export const make = 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: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.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..9d8084e3920 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,48 @@ 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, + }); + }); + + it("clears sticky telemetry preference markers when restoring defaults", () => { + const patch = buildRestoreDefaultsPatch({ + settings: { + ...DEFAULT_UNIFIED_SETTINGS, + telemetryEnabled: false, + telemetryPreferenceSet: true, + }, + isGitWritingModelDirty: false, + }); + + expect(patch).toEqual({ + telemetryPreferenceSet: false, + }); + }); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 99d7052965a..ee9c72a2ef8 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,60 @@ 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 } + : {}), + ...(input.settings.telemetryPreferenceSet !== DEFAULT_UNIFIED_SETTINGS.telemetryPreferenceSet + ? { telemetryPreferenceSet: DEFAULT_UNIFIED_SETTINGS.telemetryPreferenceSet } + : {}), + ...(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 994cbb08f23..604397ad022 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -77,6 +77,7 @@ import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, + buildRestoreDefaultsPatch, formatDiagnosticsDescription, } from "./SettingsPanels.logic"; import { @@ -424,6 +425,10 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Delete confirmation"] : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(settings.telemetryEnabled !== DEFAULT_UNIFIED_SETTINGS.telemetryEnabled || + settings.telemetryPreferenceSet !== DEFAULT_UNIFIED_SETTINGS.telemetryPreferenceSet + ? ["Telemetry"] + : []), ], [ isGitWritingModelDirty, @@ -437,6 +442,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.diffWordWrap, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, + settings.telemetryEnabled, + settings.telemetryPreferenceSet, settings.sidebarThreadPreviewCount, settings.timestampFormat, theme, @@ -454,23 +461,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, @@ -972,6 +972,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 7ba267b1e72..4ed0c85a475 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)), @@ -505,6 +507,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..bf04b3695c5 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,116 @@ 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 when the patch omits the marker", () => { + expect( + applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + telemetryEnabled: false, + telemetryPreferenceSet: true, + }, + { + enableAssistantStreaming: true, + }, + ), + ).toMatchObject({ + telemetryEnabled: false, + telemetryPreferenceSet: true, + }); + }); + + it("clears telemetry preference when the patch explicitly resets the marker", () => { + expect( + applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + telemetryEnabled: false, + telemetryPreferenceSet: true, + }, + { + telemetryPreferenceSet: false, + }, + ), + ).toMatchObject({ + telemetryEnabled: false, + telemetryPreferenceSet: false, + }); + }); + + it("clears telemetry preference when restore defaults resets telemetry", () => { + expect( + applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + telemetryEnabled: false, + telemetryPreferenceSet: true, + }, + { + telemetryEnabled: false, + telemetryPreferenceSet: false, + }, + ), + ).toMatchObject({ + telemetryEnabled: false, + telemetryPreferenceSet: false, + }); + }); + + 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..36f11a6974c 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,14 @@ export function applyServerSettingsPatch( const next = deepMerge(current, patchForMerge); const nextWithReplacements = { ...next, + ...(patch.telemetryPreferenceSet === false && + (patch.telemetryEnabled === undefined || patch.telemetryEnabled === false) + ? { telemetryPreferenceSet: false } + : current.telemetryPreferenceSet || + patch.telemetryEnabled !== undefined || + patch.telemetryPreferenceSet === true + ? { telemetryPreferenceSet: true } + : {}), ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}),