diff --git a/__tests__/hooks/use-telemetry.test.tsx b/__tests__/hooks/use-telemetry.test.tsx new file mode 100644 index 0000000000..66f3e33c95 --- /dev/null +++ b/__tests__/hooks/use-telemetry.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +// Mock posthog-js before importing hook +vi.mock("posthog-js", () => ({ + default: { + init: vi.fn(), + capture: vi.fn(), + opt_in_capturing: vi.fn(), + opt_out_capturing: vi.fn(), + reset: vi.fn(), + register: vi.fn(), + }, +})); + +import posthog from "posthog-js"; +import { useTelemetry } from "#/hooks/use-telemetry"; + +describe("useTelemetry", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("returns pending consent initially", () => { + const { result } = renderHook(() => useTelemetry()); + + expect(result.current.consent).toBe("pending"); + expect(result.current.isEnabled).toBe(false); + expect(result.current.showConsentPrompt).toBe(true); + }); + + it("returns granted consent when already granted in localStorage", () => { + localStorage.setItem("openhands-telemetry-consent", "granted"); + + const { result } = renderHook(() => useTelemetry()); + + expect(result.current.consent).toBe("granted"); + expect(result.current.isEnabled).toBe(true); + expect(result.current.showConsentPrompt).toBe(false); + }); + + it("returns denied consent when already denied in localStorage", () => { + localStorage.setItem("openhands-telemetry-consent", "denied"); + + const { result } = renderHook(() => useTelemetry()); + + expect(result.current.consent).toBe("denied"); + expect(result.current.isEnabled).toBe(false); + expect(result.current.showConsentPrompt).toBe(false); + }); + + it("grants consent and enables telemetry", async () => { + const { result } = renderHook(() => useTelemetry()); + + await act(async () => { + await result.current.grantConsent(); + }); + + expect(result.current.consent).toBe("granted"); + expect(result.current.isEnabled).toBe(true); + expect(result.current.showConsentPrompt).toBe(false); + expect(localStorage.getItem("openhands-telemetry-consent")).toBe("granted"); + }); + + it("denies consent and disables telemetry", async () => { + const { result } = renderHook(() => useTelemetry()); + + await act(async () => { + await result.current.denyConsent(); + }); + + expect(result.current.consent).toBe("denied"); + expect(result.current.isEnabled).toBe(false); + expect(result.current.showConsentPrompt).toBe(false); + expect(localStorage.getItem("openhands-telemetry-consent")).toBe("denied"); + }); + + it("track function does nothing when consent is not granted", () => { + const { result } = renderHook(() => useTelemetry()); + + act(() => { + result.current.track("test_event", { foo: "bar" }); + }); + + expect(posthog.capture).not.toHaveBeenCalled(); + }); + + it("track function calls trackEvent when consent is granted", () => { + localStorage.setItem("openhands-telemetry-consent", "granted"); + + const { result } = renderHook(() => useTelemetry()); + + // Verify that calling track when consent is granted doesn't throw + // and that it gets dispatched (the actual PostHog call is tested in telemetry.test.ts) + expect(() => { + act(() => { + result.current.track("test_event", { foo: "bar" }); + }); + }).not.toThrow(); + }); + + it("clearData resets consent to pending", async () => { + const { result } = renderHook(() => useTelemetry()); + + await act(async () => { + await result.current.grantConsent(); + }); + + expect(result.current.consent).toBe("granted"); + + act(() => { + result.current.clearData(); + }); + + expect(result.current.consent).toBe("pending"); + expect(result.current.showConsentPrompt).toBe(true); + }); +}); diff --git a/__tests__/services/telemetry.test.ts b/__tests__/services/telemetry.test.ts new file mode 100644 index 0000000000..a5a9779ddb --- /dev/null +++ b/__tests__/services/telemetry.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; + +// Mock posthog-js before importing telemetry service +const mockPosthog = { + init: vi.fn(), + capture: vi.fn(), + opt_in_capturing: vi.fn(), + opt_out_capturing: vi.fn(), + reset: vi.fn(), + register: vi.fn(), +}; + +vi.mock("posthog-js", () => ({ + default: mockPosthog, +})); + +import { + getTelemetryConsent, + setTelemetryConsent, + isTelemetryEnabled, + trackFirstUse, + trackEvent, + clearTelemetryData, +} from "#/services/telemetry"; + +// Mock import.meta.env for tests +vi.stubGlobal("import.meta", { + env: { + DEV: false, + VITE_DO_NOT_TRACK: undefined, + }, +}); + +describe("Telemetry Service", () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + sessionStorage.clear(); + // Reset mock + vi.clearAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + describe("getTelemetryConsent", () => { + it("returns 'pending' when no consent has been set", () => { + expect(getTelemetryConsent()).toBe("pending"); + }); + + it("returns 'granted' when consent is granted", () => { + localStorage.setItem("openhands-telemetry-consent", "granted"); + expect(getTelemetryConsent()).toBe("granted"); + }); + + it("returns 'denied' when consent is denied", () => { + localStorage.setItem("openhands-telemetry-consent", "denied"); + expect(getTelemetryConsent()).toBe("denied"); + }); + }); + + describe("setTelemetryConsent", () => { + it("stores granted consent in localStorage", async () => { + await setTelemetryConsent("granted"); + expect(localStorage.getItem("openhands-telemetry-consent")).toBe( + "granted", + ); + }); + + it("stores denied consent in localStorage", async () => { + await setTelemetryConsent("denied"); + expect(localStorage.getItem("openhands-telemetry-consent")).toBe( + "denied", + ); + }); + }); + + describe("isTelemetryEnabled", () => { + it("returns false when consent is pending", () => { + expect(isTelemetryEnabled()).toBe(false); + }); + + it("returns true when consent is granted", async () => { + await setTelemetryConsent("granted"); + expect(isTelemetryEnabled()).toBe(true); + }); + + it("returns false when consent is denied", async () => { + await setTelemetryConsent("denied"); + expect(isTelemetryEnabled()).toBe(false); + }); + }); + + describe("trackFirstUse", () => { + it("does not send event when consent is not granted", async () => { + await trackFirstUse(); + expect(mockPosthog.capture).not.toHaveBeenCalled(); + }); + + it("sends event when consent is granted", async () => { + await setTelemetryConsent("granted"); + await trackFirstUse(); + + expect(mockPosthog.capture).toHaveBeenCalledTimes(1); + expect(mockPosthog.capture).toHaveBeenCalledWith( + "canvas_install", + expect.objectContaining({ + platform: expect.any(String), + user_agent: expect.any(String), + }), + ); + }); + + it("only sends first use event once", async () => { + await setTelemetryConsent("granted"); + + await trackFirstUse(); + await trackFirstUse(); + await trackFirstUse(); + + // Should only be called once + expect(mockPosthog.capture).toHaveBeenCalledTimes(1); + }); + + it("includes correct event data", async () => { + await setTelemetryConsent("granted"); + await trackFirstUse(); + + expect(mockPosthog.capture).toHaveBeenCalledWith( + "canvas_install", + expect.objectContaining({ + platform: expect.any(String), + user_agent: expect.any(String), + referrer: expect.any(String), + url_origin: expect.any(String), + embedded: expect.any(Boolean), + }), + ); + }); + }); + + describe("trackEvent", () => { + it("does not send event when consent is not granted", async () => { + await trackEvent("test_event", { foo: "bar" }); + expect(mockPosthog.capture).not.toHaveBeenCalled(); + }); + + it("sends custom event when consent is granted", async () => { + await setTelemetryConsent("granted"); + await trackEvent("custom_action", { button: "submit" }); + + expect(mockPosthog.capture).toHaveBeenCalledWith("custom_action", { + button: "submit", + }); + }); + }); + + describe("clearTelemetryData", () => { + it("clears all telemetry data from localStorage", async () => { + await setTelemetryConsent("granted"); + localStorage.setItem("openhands-telemetry-first-use", "true"); + + await clearTelemetryData(); + + expect(localStorage.getItem("openhands-telemetry-consent")).toBeNull(); + expect(localStorage.getItem("openhands-telemetry-first-use")).toBeNull(); + }); + }); + + describe("PostHog integration", () => { + it("calls opt_in_capturing when consent is granted", async () => { + await setTelemetryConsent("granted"); + expect(mockPosthog.opt_in_capturing).toHaveBeenCalled(); + }); + + it("calls opt_out_capturing when consent is denied", async () => { + await setTelemetryConsent("denied"); + expect(mockPosthog.opt_out_capturing).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/features/analytics/telemetry-consent-banner.tsx b/src/components/features/analytics/telemetry-consent-banner.tsx new file mode 100644 index 0000000000..4f60acc099 --- /dev/null +++ b/src/components/features/analytics/telemetry-consent-banner.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useTelemetry } from "#/hooks/use-telemetry"; +import { I18nKey } from "#/i18n/declaration"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { + BaseModalTitle, + BaseModalDescription, +} from "#/components/shared/modals/confirmation-modals/base-modal"; +import { BrandButton } from "#/components/features/settings/brand-button"; + +interface TelemetryConsentBannerProps { + /** Called after user makes a choice */ + onChoice?: (granted: boolean) => void; +} + +/** + * A consent modal for telemetry/analytics that appears on first use. + * + * This component: + * - Shows as a full-screen modal overlay when consent is pending + * - Allows users to accept or decline tracking via checkbox + * - Respects DO_NOT_TRACK environment variable + * - Persists choice in localStorage + * - Styled to match the OpenHands analytics consent modal + * + * @example + * ```tsx + * function App() { + * return ( + * <> + * + * + * + * ); + * } + * ``` + */ +export function TelemetryConsentBanner({ + onChoice, +}: TelemetryConsentBannerProps) { + const { t } = useTranslation("openhands"); + const { showConsentPrompt, grantConsent, denyConsent } = useTelemetry(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const analytics = formData.get("analytics") === "on"; + + if (analytics) { + grantConsent(); + } else { + denyConsent(); + } + onChoice?.(analytics); + }; + + if (!showConsentPrompt) { + return null; + } + + return ( + +
+ + + + {t(I18nKey.TELEMETRY$CONSENT_DESCRIPTION)} + + + + + + {t(I18nKey.TELEMETRY$CONFIRM_PREFERENCES)} + + +
+
+ ); +} diff --git a/src/hooks/use-telemetry.ts b/src/hooks/use-telemetry.ts new file mode 100644 index 0000000000..75a1d1f946 --- /dev/null +++ b/src/hooks/use-telemetry.ts @@ -0,0 +1,104 @@ +import { useEffect, useState, useCallback } from "react"; +import { + getTelemetryConsent, + setTelemetryConsent, + trackFirstUse, + trackSessionStart, + trackEvent, + clearTelemetryData, + type TelemetryConsent, +} from "#/services/telemetry"; + +export interface UseTelemetryReturn { + /** Current consent status */ + consent: TelemetryConsent; + /** Whether telemetry is enabled (consent granted) */ + isEnabled: boolean; + /** Whether consent prompt should be shown */ + showConsentPrompt: boolean; + /** Grant consent and enable telemetry */ + grantConsent: () => void; + /** Deny consent and disable telemetry */ + denyConsent: () => void; + /** Track a custom event (only if consent granted) */ + track: (eventName: string, properties?: Record) => void; + /** Clear all telemetry data */ + clearData: () => void; +} + +/** + * Hook for managing telemetry consent and tracking. + * + * This hook handles: + * - Checking and setting user consent + * - Tracking first use automatically when consent is granted + * - Providing a simple API for tracking custom events + * + * @example + * ```tsx + * function MyComponent() { + * const { consent, showConsentPrompt, grantConsent, denyConsent, track } = useTelemetry(); + * + * useEffect(() => { + * track('component_mounted', { component: 'MyComponent' }); + * }, [track]); + * + * if (showConsentPrompt) { + * return ; + * } + * + * return
...
; + * } + * ``` + */ +export function useTelemetry(): UseTelemetryReturn { + const [consent, setConsentState] = useState(() => + getTelemetryConsent(), + ); + + // Track first use and session start when consent is granted + // Note: trackFirstUse() has built-in deduplication via localStorage, + // so it's safe to call multiple times - it only sends once per install + useEffect(() => { + if (consent === "granted") { + trackFirstUse(); + trackSessionStart(); + } + }, [consent]); + + const grantConsent = useCallback(async () => { + // Must await to ensure PostHog is initialized and opt_in_capturing() is called + // before the useEffect triggers tracking calls + await setTelemetryConsent("granted"); + setConsentState("granted"); + }, []); + + const denyConsent = useCallback(async () => { + await setTelemetryConsent("denied"); + setConsentState("denied"); + }, []); + + const track = useCallback( + (eventName: string, properties?: Record) => { + if (consent === "granted") { + trackEvent(eventName, properties); + } + }, + [consent], + ); + + const clearData = useCallback(() => { + clearTelemetryData(); + setConsentState("pending"); + }, []); + + return { + consent, + isEnabled: consent === "granted", + showConsentPrompt: consent === "pending", + grantConsent, + denyConsent, + track, + clearData, + }; +} diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 3f3d24a78d..374363fe4c 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -21855,5 +21855,124 @@ "ca": "Tancar", "tr": "Kapat", "uk": "Закрити" + }, + "TELEMETRY$CONSENT_TITLE": { + "en": "Help improve OpenHands", + "ja": "OpenHandsの改善にご協力ください", + "zh-CN": "帮助改进 OpenHands", + "zh-TW": "幫助改進 OpenHands", + "ko-KR": "OpenHands 개선에 도움을 주세요", + "no": "Hjelp med å forbedre OpenHands", + "ar": "ساعد في تحسين OpenHands", + "de": "Helfen Sie, OpenHands zu verbessern", + "fr": "Aidez à améliorer OpenHands", + "it": "Aiuta a migliorare OpenHands", + "pt": "Ajude a melhorar o OpenHands", + "es": "Ayuda a mejorar OpenHands", + "ca": "Ajuda a millorar OpenHands", + "tr": "OpenHands'i geliştirmeye yardım edin", + "uk": "Допоможіть покращити OpenHands" + }, + "TELEMETRY$CONSENT_DESCRIPTION": { + "en": "We collect anonymous usage data to improve the product. No personal information is collected. You can change this setting anytime.", + "ja": "製品の改善のため、匿名の使用データを収集しています。個人情報は収集されません。この設定はいつでも変更できます。", + "zh-CN": "我们收集匿名使用数据以改进产品。不会收集任何个人信息。您可以随时更改此设置。", + "zh-TW": "我們收集匿名使用數據以改進產品。不會收集任何個人資訊。您可以隨時更改此設定。", + "ko-KR": "제품 개선을 위해 익명의 사용 데이터를 수집합니다. 개인 정보는 수집되지 않습니다. 이 설정은 언제든지 변경할 수 있습니다.", + "no": "Vi samler inn anonyme bruksdata for å forbedre produktet. Ingen personlig informasjon samles inn. Du kan endre denne innstillingen når som helst.", + "ar": "نجمع بيانات استخدام مجهولة لتحسين المنتج. لا يتم جمع أي معلومات شخصية. يمكنك تغيير هذا الإعداد في أي وقت.", + "de": "Wir sammeln anonyme Nutzungsdaten, um das Produkt zu verbessern. Es werden keine persönlichen Daten erfasst. Sie können diese Einstellung jederzeit ändern.", + "fr": "Nous collectons des données d'utilisation anonymes pour améliorer le produit. Aucune information personnelle n'est collectée. Vous pouvez modifier ce paramètre à tout moment.", + "it": "Raccogliamo dati di utilizzo anonimi per migliorare il prodotto. Non vengono raccolte informazioni personali. Puoi modificare questa impostazione in qualsiasi momento.", + "pt": "Coletamos dados de uso anônimos para melhorar o produto. Nenhuma informação pessoal é coletada. Você pode alterar esta configuração a qualquer momento.", + "es": "Recopilamos datos de uso anónimos para mejorar el producto. No se recopila información personal. Puede cambiar esta configuración en cualquier momento.", + "ca": "Recopilem dades d'ús anònimes per millorar el producte. No es recopila informació personal. Podeu canviar aquesta configuració en qualsevol moment.", + "tr": "Ürünü geliştirmek için anonim kullanım verileri topluyoruz. Kişisel bilgi toplanmaz. Bu ayarı istediğiniz zaman değiştirebilirsiniz.", + "uk": "Ми збираємо анонімні дані про використання для покращення продукту. Особиста інформація не збирається. Ви можете змінити цей параметр у будь-який час." + }, + "TELEMETRY$ACCEPT": { + "en": "Accept", + "ja": "同意する", + "zh-CN": "接受", + "zh-TW": "接受", + "ko-KR": "수락", + "no": "Godta", + "ar": "قبول", + "de": "Akzeptieren", + "fr": "Accepter", + "it": "Accetta", + "pt": "Aceitar", + "es": "Aceptar", + "ca": "Acceptar", + "tr": "Kabul et", + "uk": "Прийняти" + }, + "TELEMETRY$DECLINE": { + "en": "Decline", + "ja": "拒否する", + "zh-CN": "拒绝", + "zh-TW": "拒絕", + "ko-KR": "거절", + "no": "Avslå", + "ar": "رفض", + "de": "Ablehnen", + "fr": "Refuser", + "it": "Rifiuta", + "pt": "Recusar", + "es": "Rechazar", + "ca": "Rebutjar", + "tr": "Reddet", + "uk": "Відхилити" + }, + "TELEMETRY$OPT_OUT_HINT": { + "en": "Set VITE_DO_NOT_TRACK=1 to disable telemetry globally.", + "ja": "VITE_DO_NOT_TRACK=1を設定すると、グローバルでテレメトリを無効にできます。", + "zh-CN": "设置 VITE_DO_NOT_TRACK=1 可全局禁用遥测。", + "zh-TW": "設定 VITE_DO_NOT_TRACK=1 可全域停用遙測。", + "ko-KR": "VITE_DO_NOT_TRACK=1을 설정하여 전역적으로 원격 측정을 비활성화합니다.", + "no": "Sett VITE_DO_NOT_TRACK=1 for å deaktivere telemetri globalt.", + "ar": "اضبط VITE_DO_NOT_TRACK=1 لتعطيل القياس عن بُعد عالميًا.", + "de": "Setzen Sie VITE_DO_NOT_TRACK=1, um Telemetrie global zu deaktivieren.", + "fr": "Définissez VITE_DO_NOT_TRACK=1 pour désactiver la télémétrie globalement.", + "it": "Imposta VITE_DO_NOT_TRACK=1 per disabilitare la telemetria globalmente.", + "pt": "Defina VITE_DO_NOT_TRACK=1 para desativar a telemetria globalmente.", + "es": "Establezca VITE_DO_NOT_TRACK=1 para deshabilitar la telemetría globalmente.", + "ca": "Establiu VITE_DO_NOT_TRACK=1 per desactivar la telemetria globalment.", + "tr": "Telemetriyi global olarak devre dışı bırakmak için VITE_DO_NOT_TRACK=1 ayarlayın.", + "uk": "Встановіть VITE_DO_NOT_TRACK=1, щоб глобально вимкнути телеметрію." + }, + "TELEMETRY$SEND_ANONYMOUS_DATA": { + "en": "Send anonymous usage data", + "ja": "匿名の使用データを送信する", + "zh-CN": "发送匿名使用数据", + "zh-TW": "傳送匿名使用數據", + "ko-KR": "익명 사용 데이터 전송", + "no": "Send anonyme bruksdata", + "ar": "إرسال بيانات الاستخدام المجهولة", + "de": "Anonyme Nutzungsdaten senden", + "fr": "Envoyer des données d'utilisation anonymes", + "it": "Invia dati di utilizzo anonimi", + "pt": "Enviar dados de uso anônimos", + "es": "Enviar datos de uso anónimos", + "ca": "Envia dades d'ús anònimes", + "tr": "Anonim kullanım verileri gönder", + "uk": "Надіслати анонімні дані використання" + }, + "TELEMETRY$CONFIRM_PREFERENCES": { + "en": "Confirm preferences", + "ja": "設定を確認", + "zh-CN": "确认偏好设置", + "zh-TW": "確認偏好設定", + "ko-KR": "환경 설정 확인", + "no": "Bekreft preferanser", + "ar": "تأكيد التفضيلات", + "de": "Einstellungen bestätigen", + "fr": "Confirmer les préférences", + "it": "Conferma preferenze", + "pt": "Confirmar preferências", + "es": "Confirmar preferencias", + "ca": "Confirma les preferències", + "tr": "Tercihleri onayla", + "uk": "Підтвердити налаштування" } } diff --git a/src/lib/index.ts b/src/lib/index.ts index 526dd08340..e8f2cad6e1 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -38,3 +38,18 @@ export { type AgentServerUIStyleOverrides, type AgentServerUITheme, } from "../styles/agent-server-ui-style-scope"; + +// Telemetry exports +export { TelemetryConsentBanner } from "../components/features/analytics/telemetry-consent-banner"; +export { useTelemetry, type UseTelemetryReturn } from "../hooks/use-telemetry"; +export { + getTelemetryConsent, + setTelemetryConsent, + isTelemetryEnabled, + trackFirstUse, + trackSessionStart, + trackEvent, + clearTelemetryData, + getPostHogInstance, + type TelemetryConsent, +} from "../services/telemetry"; diff --git a/src/root.tsx b/src/root.tsx index 67e1117d62..4c72028546 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -20,6 +20,7 @@ import { MINIMUM_SUPPORTED_AGENT_SERVER_VERSION, } from "#/api/agent-server-compatibility"; import { AgentServerConnectionForm } from "#/components/features/settings/agent-server-onboarding"; +import { TelemetryConsentBanner } from "#/components/features/analytics/telemetry-consent-banner"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { useConfig } from "#/hooks/query/use-config"; import { AgentServerUIRoot } from "#/components/providers"; @@ -37,6 +38,7 @@ export function Layout({ children }: { children: React.ReactNode }) { {children} +