diff --git a/.changeset/cloud-anonymous-analytics-optout.md b/.changeset/cloud-anonymous-analytics-optout.md new file mode 100644 index 0000000000..b6662cfb1c --- /dev/null +++ b/.changeset/cloud-anonymous-analytics-optout.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': patch +--- + +Allow Cloud administrators to disable anonymous Roomote telemetry, preserve their choice through setup, and drop queued events after opt-out. diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 7288e29c98..5c7095c3b0 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -132,7 +132,7 @@ as per-task auth tokens or workspace paths. | `R_API_SHUTDOWN_DRAIN_MS` | Optional | Milliseconds the API allows in-flight session turns to finish during shutdown before aborting the remainder so they resume on the next process. Defaults to `20000`; set to `0` to abort immediately. Keep it below the hosting platform's SIGTERM-to-SIGKILL grace period. Also the fallback for `R_BULLMQ_SHUTDOWN_DRAIN_MS`. | | `R_BULLMQ_SHUTDOWN_DRAIN_MS` | Optional | Same window for the bullmq service, which executes the session turns the queue resumes. Defaults to `R_API_SHUTDOWN_DRAIN_MS`, then `20000`. | | `R_FAST_DURABLE_RETRY_DISABLED` | Optional | Set to `true` to keep inference retry waits in the current process instead of parking in-flight session turns durably. Durable admission remains enabled. | -| `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. | +| `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud support and product-experience integrations. Anonymous Roomote telemetry remains controlled by **Settings > Deployment > Privacy**. Do not set this for self-hosted deployments. | | `R_CURATED_INTEGRATIONS_DISABLED` | Optional | Operator policy for the curated **Integrations** catalog, which is enabled by default. Set to `true` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored while disabled and become available again once the value is unset. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | | `R_GITHUB_APP_SLUG` | GitHub setup | Primary GitHub App slug used by server-rendered setup, mentions, and GitHub integration flows. | | `R_GITHUB_ADDITIONAL_APP_SLUGS` | Optional GitHub setup | Comma-separated additional GitHub App slugs that Roomote should trust as managed bot identities for review attribution and notifications. | diff --git a/apps/docs/privacy.mdx b/apps/docs/privacy.mdx index 35f019f3c6..0253477f82 100644 --- a/apps/docs/privacy.mdx +++ b/apps/docs/privacy.mdx @@ -6,9 +6,8 @@ description: Understand Roomote data handling, anonymous telemetry, and the cont Roomote keeps the data boundaries for a deployment explicit. Product content, credentials, prompts, code, and repository names are not part of the default -anonymous telemetry payload. Deployment administrators control whether a -self-hosted deployment shares that telemetry, while Roomote Cloud follows its -own privacy policy. +anonymous telemetry payload. Deployment administrators control whether +self-hosted and Roomote Cloud deployments share that telemetry. This page covers the telemetry payload, its identifiers, the controls that turn it off, and the separate version-availability check. It does not replace the @@ -30,7 +29,7 @@ integration providers you connect. ## Anonymous telemetry Roomote can share **anonymous telemetry** with the Roomote team to help improve -the product. This is on by default for self-hosted deployments, controlled +the product. This is on by default for self-hosted and Roomote Cloud deployments, controlled entirely by deployment admins, and designed so that Roomote-generated identifiers and the default payload cannot identify your company, users, code, or repositories. @@ -82,19 +81,20 @@ sends the configured value with telemetry and version checks. Anonymous telemetry is opt-out: -- during setup, the final step includes an **Anonymous analytics** switch -- afterwards, admins can change it any time in **Settings > Deployment** +- admins can change it any time in **Settings > Deployment > Privacy** +- turn off **Anonymous analytics** to opt the deployment out -The setting applies to everyone in the deployment. When disabled, no -analytics code runs in the browser and no usage events or daily reports -leave your servers. +The setting applies to everyone in the deployment. When disabled, anonymous +usage events and daily reports are not sent, and unsent event batches are +dropped. Other open pages receive the updated browser setting when refreshed; +the server also enforces the choice on incoming telemetry and before delivery. ## Roomote Cloud Specifics -Roomote Cloud deployments always keep Roomote anonymous analytics enabled and -do not show this switch, as described in our privacy policy. Cookie consent -controls only optional support and product-experience services such as Intercom -and PostHog. +Roomote Cloud administrators use the same **Anonymous analytics** setting. +Cookie consent separately controls optional support and product-experience +services such as Intercom and PostHog. Changing anonymous analytics does not +change that consent. ## Version checks diff --git a/apps/web/src/app/api/telemetry/route.ts b/apps/web/src/app/api/telemetry/route.ts index e3ffbe57eb..ee05d50ee0 100644 --- a/apps/web/src/app/api/telemetry/route.ts +++ b/apps/web/src/app/api/telemetry/route.ts @@ -11,7 +11,6 @@ import { } from '@roomote/telemetry/server'; import { authorize } from '@/lib/server/auth-context'; -import { Env, isRoomoteCloudEnabled } from '@/lib/server/env'; export const runtime = 'nodejs'; @@ -55,17 +54,12 @@ export async function POST(request: NextRequest) { // captureEvent re-checks this per event; checking once up front just // avoids pointless id resolution work when analytics is disabled. - if ( - !(await isAnonymousAnalyticsEnabled( - isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED), - )) - ) { + if (!(await isAnonymousAnalyticsEnabled())) { return NextResponse.json({ accepted: 0 }, { status: 202 }); } for (const event of parsed.data.events) { void captureEvent(event.event, { - cloudEnabled: isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED), userId: authResult.userId, properties: event.properties, timestamp: event.timestamp, diff --git a/apps/web/src/components/settings/MiscSettings.client.test.tsx b/apps/web/src/components/settings/MiscSettings.client.test.tsx index 16f68af95b..1adfdcd6e2 100644 --- a/apps/web/src/components/settings/MiscSettings.client.test.tsx +++ b/apps/web/src/components/settings/MiscSettings.client.test.tsx @@ -3,6 +3,11 @@ import { fireEvent, render, screen } from '@testing-library/react'; const mocks = vi.hoisted(() => ({ setQueryData: vi.fn(), setAnonymousAnalytics: vi.fn(), + refresh: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: mocks.refresh }), })); vi.mock('@tanstack/react-query', () => ({ @@ -54,6 +59,14 @@ vi.mock('./DeploymentTimeZoneSetting', () => ({ import { MiscSettings } from './MiscSettings'; describe('MiscSettings', () => { + it('offers the existing anonymous analytics control in Cloud mode', () => { + render(); + + expect( + screen.getByRole('switch', { name: /anonymous analytics/i }), + ).toBeChecked(); + }); + it('does not render the Private Sessions experiment', () => { render(); diff --git a/apps/web/src/components/settings/MiscSettings.tsx b/apps/web/src/components/settings/MiscSettings.tsx index f8f550a44e..8b7fe56f33 100644 --- a/apps/web/src/components/settings/MiscSettings.tsx +++ b/apps/web/src/components/settings/MiscSettings.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { useTRPC } from '@/trpc/client'; @@ -32,6 +33,7 @@ function getBugReportUrl(diagnostics: string): string { } export function MiscSettings() { + const router = useRouter(); const trpc = useTRPC(); const queryClient = useQueryClient(); const queryKey = trpc.miscSettings.get.queryKey(); @@ -52,6 +54,7 @@ export function MiscSettings() { try { const updated = await updateMutation.mutateAsync({ enabled: nextValue }); queryClient.setQueryData(queryKey, updated); + router.refresh(); toast.success( `Anonymous analytics ${nextValue ? 'enabled' : 'disabled'}`, ); @@ -130,27 +133,25 @@ export function MiscSettings() { - {!settingsQuery.data.cloudEnabled && ( -
-
- void handleToggle(checked === true)} - /> -
-

Anonymous analytics

-

- Share anonymous usage analytics with the Roomote team to help - improve the product. Activity is identified only by random IDs - that are never linked to your company, users, or repositories. - No prompts, conversations or code is ever shared. -

-
+
+
+ void handleToggle(checked === true)} + /> +
+

Anonymous analytics

+

+ Share anonymous usage analytics with the Roomote team to help + improve the product. Activity is identified only by random IDs + that are never linked to your company, users, or repositories. No + prompts, conversations or code is ever shared. +

-
- )} +
+
diff --git a/apps/web/src/lib/server/auth-context.ts b/apps/web/src/lib/server/auth-context.ts index 251d8d00bc..cfc115f8f7 100644 --- a/apps/web/src/lib/server/auth-context.ts +++ b/apps/web/src/lib/server/auth-context.ts @@ -435,10 +435,7 @@ export async function authorize( const anonymousAnalyticsEnabled = isTelemetryEnvAllowed() && - isAnonymousAnalyticsEnabledFromMetadata( - authContext.deploymentMetadata, - isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED), - ); + isAnonymousAnalyticsEnabledFromMetadata(authContext.deploymentMetadata); return { success: true, diff --git a/apps/web/src/trpc/commands/misc-settings/anonymous-analytics.db.test.ts b/apps/web/src/trpc/commands/misc-settings/anonymous-analytics.db.test.ts new file mode 100644 index 0000000000..656a00869d --- /dev/null +++ b/apps/web/src/trpc/commands/misc-settings/anonymous-analytics.db.test.ts @@ -0,0 +1,172 @@ +import { createServer } from 'node:http'; +import { db, deploymentSettings, eq } from '@roomote/db/server'; +import { ANONYMOUS_ANALYTICS_METADATA_KEY } from '@roomote/feature-flags'; +import { + captureInstanceEvent, + flushTelemetry, +} from '@roomote/telemetry/server'; +import type { UserAuthSuccess } from '@/types'; + +const mode = vi.hoisted(() => ({ cloud: true, ping: '' })); + +vi.mock('@roomote/env', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Env: new Proxy(actual.Env, { + get(target, key) { + if (key === 'R_CLOUD_ENABLED') return 'true'; + if (key === 'APP_ENV') return 'development'; + if (key === 'ROOMOTE_FORCE_TELEMETRY') return 'true'; + if (key === 'R_PING_BASE_URL') return mode.ping; + if (key === 'R_INSTANCE_ID') return 'synthetic_analytics_test'; + return Reflect.get(target, key); + }, + }), + }; +}); + +vi.mock('@/lib/server/env', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Env: new Proxy(actual.Env, { + get(target, key) { + if (key === 'R_CLOUD_ENABLED') return mode.cloud ? 'true' : 'false'; + return Reflect.get(target, key); + }, + }), + }; +}); + +// Diagnostics may probe storage; persistence and the setting evaluator stay real. +vi.mock('@/lib/server/s3-client', () => ({ + getS3Client: () => ({ send: async () => ({}) }), +})); + +import { getMiscSettingsCommand, setAnonymousAnalyticsCommand } from './index'; + +const admin = { isAdmin: true } as UserAuthSuccess; +let original: typeof deploymentSettings.$inferSelect | undefined; +let received: string[] = []; +const recorder = createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) body += chunk.toString(); + const parsed = JSON.parse(body); + received.push( + ...parsed.events.map((event: { event: string }) => event.event), + ); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{"ok":true}'); +}); + +beforeAll(async () => { + await new Promise((resolve) => + recorder.listen(0, '127.0.0.1', resolve), + ); + const address = recorder.address(); + if (!address || typeof address === 'string') + throw new Error('Missing recorder address'); + mode.ping = `http://127.0.0.1:${address.port}`; + vi.stubEnv('R_PING_BASE_URL', mode.ping); +}); + +afterAll(async () => { + await new Promise((resolve) => recorder.close(() => resolve())); + vi.unstubAllEnvs(); +}); + +beforeEach(async () => { + mode.cloud = true; + received = []; + original = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + }); + const metadata = { + ...(original?.metadata as Record | null), + [ANONYMOUS_ANALYTICS_METADATA_KEY]: true, + analyticsProofSibling: 'preserve', + }; + await db + .insert(deploymentSettings) + .values({ id: 'default', metadata }) + .onConflictDoUpdate({ target: deploymentSettings.id, set: { metadata } }); +}); + +afterEach(async () => { + if (original) { + await db + .update(deploymentSettings) + .set({ metadata: original.metadata }) + .where(eq(deploymentSettings.id, 'default')); + } else { + await db + .delete(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')); + } +}); + +describe('anonymous analytics administrator choice', () => { + it.each([false, true])( + 'persists opt-out and opt-in with Cloud=%s', + async (cloud) => { + mode.cloud = cloud; + const disabled = await setAnonymousAnalyticsCommand(admin, { + enabled: false, + }); + expect(disabled.cloudEnabled).toBe(cloud); + expect(disabled.anonymousAnalyticsEnabled).toBe(false); + const stored = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + }); + expect(stored?.metadata).toMatchObject({ + [ANONYMOUS_ANALYTICS_METADATA_KEY]: false, + analyticsProofSibling: 'preserve', + }); + expect( + (await setAnonymousAnalyticsCommand(admin, { enabled: true })) + .anonymousAnalyticsEnabled, + ).toBe(true); + }, + ); + + it('keeps the unset Cloud default enabled', async () => { + await db + .update(deploymentSettings) + .set({ metadata: { analyticsProofSibling: 'preserve' } }) + .where(eq(deploymentSettings.id, 'default')); + expect( + (await getMiscSettingsCommand(admin)).anonymousAnalyticsEnabled, + ).toBe(true); + await captureInstanceEvent('analytics_unset_positive'); + await flushTelemetry(); + expect(received).toEqual(['analytics_unset_positive']); + }); + + it('stops new and already queued server events after Cloud opt-out', async () => { + await captureInstanceEvent('analytics_enabled_positive'); + await flushTelemetry(); + expect(received).toEqual(['analytics_enabled_positive']); + received = []; + + await captureInstanceEvent('analytics_queued_before_optout'); + await setAnonymousAnalyticsCommand(admin, { enabled: false }); + await captureInstanceEvent('analytics_after_optout'); + await flushTelemetry(); + expect(received).toEqual([]); + }); + + it('does not permit a member to change deployment consent', async () => { + await expect( + setAnonymousAnalyticsCommand({ isAdmin: false } as UserAuthSuccess, { + enabled: false, + }), + ).rejects.toThrow(); + const stored = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + }); + expect(stored?.metadata).toMatchObject({ + [ANONYMOUS_ANALYTICS_METADATA_KEY]: true, + }); + }); +}); diff --git a/apps/web/src/trpc/commands/misc-settings/index.ts b/apps/web/src/trpc/commands/misc-settings/index.ts index e2c4adbd76..2c51f09560 100644 --- a/apps/web/src/trpc/commands/misc-settings/index.ts +++ b/apps/web/src/trpc/commands/misc-settings/index.ts @@ -586,10 +586,8 @@ export async function getMiscSettingsCommand( ]); return { - anonymousAnalyticsEnabled: isAnonymousAnalyticsEnabledFromMetadata( - metadata, - isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED), - ), + anonymousAnalyticsEnabled: + isAnonymousAnalyticsEnabledFromMetadata(metadata), cloudEnabled: isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED), telemetryEnvAllowed: isTelemetryEnvAllowed(), diagnostics, @@ -687,10 +685,9 @@ export async function setAnonymousAnalyticsCommand( columns: { metadata: true }, }); - const cloudEnabled = isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED); const nextMetadata: Record = { ...normalizeMetadata(existingSettings?.metadata), - [ANONYMOUS_ANALYTICS_METADATA_KEY]: cloudEnabled ? true : input.enabled, + [ANONYMOUS_ANALYTICS_METADATA_KEY]: input.enabled, }; if (!existingSettings) { diff --git a/apps/web/src/trpc/commands/setup/index.test.ts b/apps/web/src/trpc/commands/setup/index.test.ts index d04e11fb03..05f057ddd6 100644 --- a/apps/web/src/trpc/commands/setup/index.test.ts +++ b/apps/web/src/trpc/commands/setup/index.test.ts @@ -148,6 +148,30 @@ describe('completeSetupCommand', () => { mockFindDeploymentSettings.mockResolvedValue(undefined); }); + it('honors explicit Cloud analytics opt-out while retaining the Memory default', async () => { + mockEnv.R_CLOUD_ENABLED = true; + mockFindDeploymentSettings.mockResolvedValue({ + brainEnabled: null, + metadata: { otherSetting: 'preserve' }, + setupCompletedAt: null, + setupNewState: null, + }); + + await completeSetupCommand(buildAuth({ cloudEnabled: true }), { + anonymousAnalyticsEnabled: false, + }); + + expect(mockInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + brainEnabled: true, + metadata: { + otherSetting: 'preserve', + anonymous_analytics_enabled: false, + }, + }), + ); + }); + it('defaults Memory without overwriting a concurrent explicit choice', async () => { mockEnv.R_CLOUD_ENABLED = true; mockFindDeploymentSettings.mockResolvedValue({ diff --git a/apps/web/src/trpc/commands/setup/index.ts b/apps/web/src/trpc/commands/setup/index.ts index f86b0e569d..5c2cf43d98 100644 --- a/apps/web/src/trpc/commands/setup/index.ts +++ b/apps/web/src/trpc/commands/setup/index.ts @@ -468,9 +468,7 @@ export async function completeSetupCommand( .automationRecommendations?.status === 'ready'; let metadataUpdate: Record | undefined; const cloudEnabled = isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED); - const anonymousAnalyticsEnabled = cloudEnabled - ? true - : input?.anonymousAnalyticsEnabled; + const anonymousAnalyticsEnabled = input?.anonymousAnalyticsEnabled; // Setup completion is the shared creation boundary. Default only unfinished // Cloud deployments so existing instances and explicit choices stay intact. const brainEnabledDefault = diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 6cc49c237c..5a504fab47 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -28,7 +28,10 @@ export default defineConfig({ '!src/{hooks,components}/**/*.test.{js,jsx,ts,tsx}', ], environment: 'node', - exclude: ['src/trpc/commands/snapshots/provider-cleanup.db.test.ts'], + exclude: [ + 'src/trpc/commands/snapshots/provider-cleanup.db.test.ts', + 'src/trpc/commands/misc-settings/anonymous-analytics.db.test.ts', + ], // Keep DB-backed server tests below the local Postgres connection limit. maxWorkers: 4, globalSetup: './vitest.setup.server.ts', @@ -39,12 +42,14 @@ export default defineConfig({ extends: true, test: { name: 'server-global-db-state', - include: ['src/trpc/commands/snapshots/provider-cleanup.db.test.ts'], + include: [ + 'src/trpc/commands/snapshots/provider-cleanup.db.test.ts', + 'src/trpc/commands/misc-settings/anonymous-analytics.db.test.ts', + ], environment: 'node', globalSetup: './vitest.setup.server.ts', setupFiles: './vitest.setup.mocks.ts', - // The no-eligible-provider fixture temporarily changes the shared - // deployment default; run it after ordinary server/client suites. + // Deployment-wide fixtures run after ordinary server/client suites. sequence: { groupOrder: 1 }, fileParallelism: false, maxWorkers: 1, diff --git a/packages/feature-flags/src/__tests__/anonymous-analytics.test.ts b/packages/feature-flags/src/__tests__/anonymous-analytics.test.ts index 9726ff4a69..e05694c303 100644 --- a/packages/feature-flags/src/__tests__/anonymous-analytics.test.ts +++ b/packages/feature-flags/src/__tests__/anonymous-analytics.test.ts @@ -23,13 +23,4 @@ describe('isAnonymousAnalyticsEnabledFromMetadata', () => { }), ).toBe(true); }); - - it('forces analytics enabled for Roomote Cloud', () => { - expect( - isAnonymousAnalyticsEnabledFromMetadata( - { [ANONYMOUS_ANALYTICS_METADATA_KEY]: false }, - true, - ), - ).toBe(true); - }); }); diff --git a/packages/feature-flags/src/index.ts b/packages/feature-flags/src/index.ts index d3d21233bb..7d9837d325 100644 --- a/packages/feature-flags/src/index.ts +++ b/packages/feature-flags/src/index.ts @@ -114,10 +114,7 @@ export const ANONYMOUS_ANALYTICS_METADATA_KEY = export function isAnonymousAnalyticsEnabledFromMetadata( metadata: unknown, - cloudEnabled = false, ): boolean { - if (cloudEnabled) return true; - const normalizedMetadata = normalizeMetadataRecord(metadata); if (!(ANONYMOUS_ANALYTICS_METADATA_KEY in normalizedMetadata)) return true; diff --git a/packages/telemetry/src/server/index.ts b/packages/telemetry/src/server/index.ts index 7f5b7b4372..3767defd0d 100644 --- a/packages/telemetry/src/server/index.ts +++ b/packages/telemetry/src/server/index.ts @@ -161,9 +161,7 @@ function getPingBaseUrl(): string { * telemetry AND the admin-controlled deployment setting is enabled * (opt-out: absent means enabled). */ -export async function isAnonymousAnalyticsEnabled( - cloudEnabled = isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED), -): Promise { +export async function isAnonymousAnalyticsEnabled(): Promise { if (!isTelemetryEnvAllowed()) { return false; } @@ -174,10 +172,7 @@ export async function isAnonymousAnalyticsEnabled( columns: { metadata: true }, }); - return isAnonymousAnalyticsEnabledFromMetadata( - settings?.metadata, - cloudEnabled, - ); + return isAnonymousAnalyticsEnabledFromMetadata(settings?.metadata); } catch { return false; } @@ -282,7 +277,12 @@ export async function flushTelemetry(): Promise { } const batch = pendingEvents.splice(0, pendingEvents.length); - flushInFlight = deliverBatch(batch).finally(() => { + flushInFlight = (async () => { + // Consent can change while events wait in the batch queue. + if (await isAnonymousAnalyticsEnabled()) { + await deliverBatch(batch); + } + })().finally(() => { flushInFlight = null; }); await flushInFlight; @@ -308,7 +308,6 @@ export interface CaptureEventOptions { properties?: TelemetryEventProperties; /** ISO-8601 override; defaults to now. */ timestamp?: string; - cloudEnabled?: boolean; } /** @@ -325,7 +324,7 @@ export async function captureEvent( return; } - if (!(await isAnonymousAnalyticsEnabled(options.cloudEnabled))) { + if (!(await isAnonymousAnalyticsEnabled())) { return; }