diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b7331f95e6e..c90381e41f6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -296,7 +296,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(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), @@ -312,11 +311,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..5176d344bb1 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -427,6 +427,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 +441,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { serverPassword: "secret-password", }, }, - automaticGitFetchInterval: Duration.seconds(10), + telemetryEnabled: true, }); assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); @@ -449,6 +450,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,7 +464,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { serverPassword: "secret-password", }, }, - automaticGitFetchInterval: 10_000, + telemetryEnabled: true, }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index 5aa47406d9b..839e63e72f9 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -3,12 +3,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as 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 +39,108 @@ 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("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const capturedRequests: Array = []; @@ -44,7 +148,10 @@ 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()), + ); const configLayer = ConfigProvider.layer( ConfigProvider.fromUnknown({ T3CODE_TELEMETRY_ENABLED: true, @@ -118,4 +225,160 @@ 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()), + ); + 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, + }), + ); + 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); + }), + ); }); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 27bf64c7be0..2fade8327f7 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -1,20 +1,26 @@ /** - * 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 * 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 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" }; @@ -25,6 +31,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"), @@ -32,7 +44,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), @@ -41,12 +53,41 @@ 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"; + yield* serverSettings.start.pipe( + Effect.catch((cause) => + Effect.logDebug("Failed to start telemetry settings watcher", { cause }), + ), + ); + + const isTelemetryEnabled = Effect.fn("isTelemetryEnabled")(function* () { + if (telemetryConfig.enabled) { + return true; + } + + return yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.telemetryEnabled), + Effect.catch((cause) => + Effect.logDebug("Failed to read telemetry setting", { cause }).pipe(Effect.as(false)), + ), + ); + }); + 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) => { @@ -77,7 +118,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, @@ -106,6 +154,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; @@ -119,6 +172,11 @@ const makeAnalyticsService = Effect.gen(function* () { return; } + if (!(yield* isTelemetryEnabled())) { + yield* Ref.set(bufferRef, []); + return; + } + yield* sendBatch(batch).pipe( Effect.catch((error) => Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( @@ -127,11 +185,11 @@ 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; + if (!(yield* isTelemetryEnabled())) return; const enqueueResult = yield* enqueueBufferedEvent(event, properties); if (enqueueResult.dropped) { diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index a4cdc867f24..1a8819d5896 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -780,6 +780,12 @@ describe("GeneralSettingsPanel observability", () => { .element(page.getByRole("heading", { name: "Diagnostics", exact: true })) .toBeInTheDocument(); await expect.element(page.getByRole("link", { name: "View diagnostics" })).toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Telemetry", exact: true })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("switch", { name: "Share anonymous telemetry" })) + .toHaveAttribute("aria-checked", "false"); await expect .element( page.getByText( @@ -789,6 +795,35 @@ describe("GeneralSettingsPanel observability", () => { .toBeInTheDocument(); }); + it("updates the server telemetry setting from the About section", async () => { + const updateSettings = vi.fn().mockResolvedValue({ + ...DEFAULT_SERVER_SETTINGS, + telemetryEnabled: true, + }); + window.nativeApi = { + persistence: { + getClientSettings: vi.fn().mockResolvedValue(null), + setClientSettings: vi.fn().mockResolvedValue(undefined), + }, + server: { + updateSettings, + }, + } as unknown as LocalApi; + setServerConfigSnapshot(createBaseServerConfig()); + + mounted = await renderWithTestRouter( + + + , + ); + + const telemetrySwitch = page.getByRole("switch", { name: "Share anonymous telemetry" }); + await telemetrySwitch.click(); + + expect(updateSettings).toHaveBeenCalledWith({ telemetryEnabled: true }); + await expect.element(telemetrySwitch).toHaveAttribute("aria-checked", "true"); + }); + it("creates and shows a pairing link when network access is enabled", async () => { window.desktopBridge = createDesktopBridgeStub({ serverExposureState: { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 76d5d34c355..7ec7bb9c888 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -427,6 +427,9 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Delete confirmation"] : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(settings.telemetryEnabled !== DEFAULT_UNIFIED_SETTINGS.telemetryEnabled + ? ["Telemetry"] + : []), ], [ isGitWritingModelDirty, @@ -439,6 +442,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.diffWordWrap, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, + settings.telemetryEnabled, settings.sidebarThreadPreviewCount, settings.timestampFormat, theme, @@ -463,6 +467,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, + telemetryEnabled: DEFAULT_UNIFIED_SETTINGS.telemetryEnabled, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, @@ -911,6 +916,29 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + telemetryEnabled: DEFAULT_UNIFIED_SETTINGS.telemetryEnabled, + }) + } + /> + ) : null + } + control={ + updateSettings({ telemetryEnabled: Boolean(checked) })} + aria-label="Share anonymous telemetry" + /> + } + /> ); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 04ee479bcd3..002932114a4 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -8,6 +8,13 @@ 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(decodeServerSettings({}).telemetryEnabled).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({}); @@ -94,6 +101,12 @@ describe("ServerSettingsPatch.providerInstances", () => { }); }); +describe("ServerSettingsPatch.telemetryEnabled", () => { + it("decodes telemetry opt-in patches", () => { + expect(decodeServerSettingsPatch({ telemetryEnabled: true }).telemetryEnabled).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 33781f56c94..50b2d15847d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -364,7 +364,7 @@ export type ObservabilitySettings = typeof ObservabilitySettings.Type; export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const ServerSettings = Schema.Struct({ - enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), automaticGitFetchInterval: Schema.DurationFromMillis.pipe( Schema.withDecodingDefault( Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), @@ -373,7 +373,8 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), - addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + telemetryEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -478,10 +479,11 @@ const OpenCodeSettingsPatch = Schema.Struct({ export const ServerSettingsPatch = Schema.Struct({ // Server settings - enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), + addProjectBaseDirectory: Schema.optionalKey(TrimmedString), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), - addProjectBaseDirectory: Schema.optionalKey(TrimmedString), + enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), + telemetryEnabled: Schema.optionalKey(Schema.Boolean), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( Schema.Struct({