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 (
+
+
+
+ );
+}
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}
+
diff --git a/src/services/telemetry.ts b/src/services/telemetry.ts
new file mode 100644
index 0000000000..564c276e40
--- /dev/null
+++ b/src/services/telemetry.ts
@@ -0,0 +1,379 @@
+/**
+ * Telemetry service for tracking library usage with user consent.
+ *
+ * This module handles anonymous telemetry for the @openhands/agent-canvas package
+ * using the PostHog SDK for reliable event delivery with batching, retry logic,
+ * and offline support.
+ *
+ * IMPORTANT: By default, telemetry is sent to the OpenHands PostHog project when
+ * users grant consent. Library consumers can override this by setting
+ * VITE_POSTHOG_API_KEY to point telemetry to their own PostHog project.
+ *
+ * Users can opt out of telemetry via:
+ * - Declining consent in the UI
+ * - Setting VITE_DO_NOT_TRACK=1 environment variable
+ * - Browser's Do Not Track setting
+ */
+
+import type { PostHog } from "posthog-js";
+import packageJson from "../../package.json";
+
+const TELEMETRY_CONSENT_KEY = "openhands-telemetry-consent";
+const TELEMETRY_FIRST_USE_KEY = "openhands-telemetry-first-use";
+const TELEMETRY_SESSION_KEY = "openhands-telemetry-session";
+
+// PostHog configuration - configurable via env vars with OpenHands defaults
+// Note: The default API key sends telemetry to OpenHands' PostHog project.
+// Library consumers can override this with their own PostHog project key.
+const POSTHOG_API_KEY =
+ import.meta.env.VITE_POSTHOG_API_KEY ||
+ "phc_BgzfxKdgsYMLFTmJqt424ZoyVHvKFfrwttLimzdYTKFK";
+const POSTHOG_HOST =
+ import.meta.env.VITE_POSTHOG_HOST || "https://us.i.posthog.com";
+
+export type TelemetryConsent = "granted" | "denied" | "pending";
+
+let isInitialized = false;
+let posthogInstance: PostHog | null = null;
+
+/**
+ * Check if we're in a browser environment
+ */
+function isBrowser(): boolean {
+ return typeof window !== "undefined" && typeof localStorage !== "undefined";
+}
+
+/**
+ * Lazily load PostHog to avoid SSR/Node.js issues.
+ * PostHog is a browser-only library, so we dynamically import it only when needed.
+ */
+async function getPostHog(): Promise {
+ if (!isBrowser()) {
+ return null;
+ }
+
+ if (posthogInstance) {
+ return posthogInstance;
+ }
+
+ try {
+ const { default: posthog } = await import("posthog-js");
+ posthogInstance = posthog;
+ return posthog;
+ } catch {
+ // Failed to load PostHog - telemetry will be disabled
+ return null;
+ }
+}
+
+/**
+ * Check if telemetry is disabled via environment variable or browser setting.
+ * Works in both Node.js and browser (Vite) environments.
+ */
+function isDoNotTrackEnabled(): boolean {
+ // Check Vite environment variable (browser)
+ if (
+ typeof import.meta !== "undefined" &&
+ import.meta.env?.VITE_DO_NOT_TRACK === "1"
+ ) {
+ return true;
+ }
+
+ // Check Node.js environment variable (SSR/testing)
+ if (typeof process !== "undefined" && process.env?.DO_NOT_TRACK === "1") {
+ return true;
+ }
+
+ // Check browser's navigator.doNotTrack standard
+ if (
+ typeof navigator !== "undefined" &&
+ (navigator.doNotTrack === "1" ||
+ // @ts-expect-error - Some browsers use window.doNotTrack
+ (typeof window !== "undefined" && window.doNotTrack === "1"))
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Initialize PostHog SDK (called once on first consent grant)
+ */
+async function initializePostHog(): Promise {
+ if (isInitialized) {
+ return posthogInstance;
+ }
+
+ const posthog = await getPostHog();
+ if (!posthog) {
+ return null;
+ }
+
+ posthog.init(POSTHOG_API_KEY, {
+ api_host: POSTHOG_HOST,
+ // Start with capturing disabled - we enable it when consent is granted
+ opt_out_capturing_by_default: true,
+ // Don't auto-capture page views - we control when to track
+ capture_pageview: false,
+ // Don't auto-capture clicks etc.
+ autocapture: false,
+ // Use localStorage for persistence
+ persistence: "localStorage",
+ // Disable session recording
+ disable_session_recording: true,
+ // Set default properties for all events
+ loaded: (ph) => {
+ ph.register({
+ package_name: packageJson.name,
+ package_version: packageJson.version,
+ });
+ },
+ });
+
+ isInitialized = true;
+ return posthog;
+}
+
+/**
+ * Get user's telemetry consent preference
+ */
+export function getTelemetryConsent(): TelemetryConsent {
+ if (!isBrowser()) {
+ return "pending";
+ }
+
+ // Check environment variable for opt-out
+ if (isDoNotTrackEnabled()) {
+ return "denied";
+ }
+
+ try {
+ const consent = localStorage.getItem(TELEMETRY_CONSENT_KEY);
+ if (consent === "granted" || consent === "denied") {
+ return consent;
+ }
+ } catch {
+ // Ignore storage errors
+ }
+
+ return "pending";
+}
+
+/**
+ * Set user's telemetry consent preference
+ */
+export async function setTelemetryConsent(
+ consent: "granted" | "denied",
+): Promise {
+ if (!isBrowser()) {
+ return;
+ }
+
+ try {
+ localStorage.setItem(TELEMETRY_CONSENT_KEY, consent);
+
+ // Initialize PostHog if not already done
+ const posthog = await initializePostHog();
+ if (!posthog) {
+ return;
+ }
+
+ if (consent === "granted") {
+ // Enable capturing
+ posthog.opt_in_capturing();
+ } else {
+ // Disable capturing and clear any queued events
+ posthog.opt_out_capturing();
+ }
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+/**
+ * Check if telemetry is enabled (user has granted consent)
+ */
+export function isTelemetryEnabled(): boolean {
+ return getTelemetryConsent() === "granted";
+}
+
+/**
+ * Check if first use event has already been sent
+ */
+function hasFirstUseSent(): boolean {
+ if (!isBrowser()) {
+ return false;
+ }
+
+ try {
+ return localStorage.getItem(TELEMETRY_FIRST_USE_KEY) === "true";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Mark first use event as sent
+ */
+function markFirstUseSent(): void {
+ if (!isBrowser()) {
+ return;
+ }
+
+ try {
+ localStorage.setItem(TELEMETRY_FIRST_USE_KEY, "true");
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+/**
+ * Track the first use of the library.
+ * This is called when the library components are first mounted.
+ * It only sends an event once per installation (tracked via localStorage).
+ */
+export async function trackFirstUse(): Promise {
+ // Check consent first
+ if (!isTelemetryEnabled()) {
+ return;
+ }
+
+ // Already sent first use event
+ if (hasFirstUseSent()) {
+ return;
+ }
+
+ // Initialize PostHog if needed
+ const posthog = await initializePostHog();
+ if (!posthog) {
+ return;
+ }
+
+ // Capture the event
+ posthog.capture("canvas_install", {
+ platform:
+ typeof navigator !== "undefined" ? navigator.platform : "unknown",
+ user_agent:
+ typeof navigator !== "undefined" ? navigator.userAgent : "unknown",
+ referrer: typeof document !== "undefined" ? document.referrer : "",
+ url_origin: typeof window !== "undefined" ? window.location.origin : "",
+ embedded: typeof window !== "undefined" && window.self !== window.top,
+ });
+
+ // Mark as sent
+ markFirstUseSent();
+}
+
+/**
+ * Check if session start event has already been sent (this browser session)
+ */
+function hasSessionSent(): boolean {
+ if (!isBrowser()) {
+ return false;
+ }
+
+ try {
+ return sessionStorage.getItem(TELEMETRY_SESSION_KEY) === "true";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Mark session start event as sent (uses sessionStorage so it resets on new tabs/sessions)
+ */
+function markSessionSent(): void {
+ if (!isBrowser()) {
+ return;
+ }
+
+ try {
+ sessionStorage.setItem(TELEMETRY_SESSION_KEY, "true");
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+/**
+ * Track a session start event.
+ * Called each time a new browser session starts (respects consent).
+ * Uses sessionStorage for deduplication - only sends once per browser session.
+ */
+export async function trackSessionStart(): Promise {
+ if (!isTelemetryEnabled()) {
+ return;
+ }
+
+ // Already sent session event this browser session
+ if (hasSessionSent()) {
+ return;
+ }
+
+ // Initialize PostHog if needed
+ const posthog = await initializePostHog();
+ if (!posthog) {
+ return;
+ }
+
+ posthog.capture("canvas_new_session", {
+ is_first_use: !hasFirstUseSent(),
+ });
+
+ // Mark as sent for this session
+ markSessionSent();
+}
+
+/**
+ * Track a custom event (respects consent).
+ */
+export async function trackEvent(
+ eventName: string,
+ properties: Record = {},
+): Promise {
+ if (!isTelemetryEnabled()) {
+ return;
+ }
+
+ // Initialize PostHog if needed
+ const posthog = await initializePostHog();
+ if (!posthog) {
+ return;
+ }
+
+ posthog.capture(eventName, properties);
+}
+
+/**
+ * Clear all telemetry data (for privacy/GDPR requests)
+ */
+export async function clearTelemetryData(): Promise {
+ if (!isBrowser()) {
+ return;
+ }
+
+ try {
+ localStorage.removeItem(TELEMETRY_CONSENT_KEY);
+ localStorage.removeItem(TELEMETRY_FIRST_USE_KEY);
+ sessionStorage.removeItem(TELEMETRY_SESSION_KEY);
+
+ // Reset PostHog if initialized
+ if (isInitialized && posthogInstance) {
+ posthogInstance.reset();
+ }
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+/**
+ * Get the PostHog instance for advanced usage (if needed).
+ * Returns the instance if initialized, otherwise null.
+ * Note: This is async because PostHog is lazily loaded.
+ */
+export async function getPostHogInstance(): Promise {
+ if (!isInitialized) {
+ return null;
+ }
+ return posthogInstance;
+}