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;
}