From e2615344dfd2f056f66bc4b8178e1780f022fa10 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Thu, 7 May 2026 13:18:01 -0400 Subject: [PATCH] feat: add first-use telemetry tracking with consent (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add first-use telemetry tracking with consent - Add telemetry service with consent management (src/services/telemetry.ts) - Add useTelemetry React hook for easy integration (src/hooks/use-telemetry.ts) - Add TelemetryConsentBanner component with i18n support - Add local development server for testing (scripts/telemetry-dev-server.mjs) - Add comprehensive tests for telemetry service and hook - Export telemetry utilities from library index - Respect DO_NOT_TRACK environment variable for privacy - Uses localhost:8080 endpoint for development Co-authored-by: openhands * fix: address PR review feedback - Make TELEMETRY_ENDPOINT configurable via VITE_TELEMETRY_ENDPOINT env var - Make POSTHOG_API_KEY configurable via VITE_POSTHOG_API_KEY env var - Add validation to skip telemetry if API key not configured (except localhost) - Fix DO_NOT_TRACK to work in browser environments using VITE_DO_NOT_TRACK - Also respect browser's navigator.doNotTrack standard - Update consent banner hint text to reference correct env var - Add documentation comments for all configuration options Co-authored-by: openhands * feat: hardcode PostHog credentials for centralized telemetry - Use OpenHands PostHog project API key for all library users - Use PostHog US Cloud endpoint (https://us.i.posthog.com/capture) - Remove environment variable configuration for endpoint/API key - Telemetry now automatically sends to centralized project when consent granted - Users can still opt out via UI, VITE_DO_NOT_TRACK, or browser DNT setting Co-authored-by: openhands * feat: use separate PostHog API keys for dev and production - Dev environment: phc_kBtz5nKmxVRRQ7HtPwr2QX9eMC5j65zE86QKocVNwb4U - Production: phc_BgzfxKdgsYMLFTmJqt424ZoyVHvKFfrwttLimzdYTKFK - Automatically selects key based on import.meta.env.DEV Co-authored-by: openhands * chore: use single production PostHog API key everywhere Simplify by using the same API key for all environments. Co-authored-by: openhands * chore: rename telemetry events - library_first_use → canvas_install - library_session_start → canvas_new_session Co-authored-by: openhands * feat: migrate telemetry to PostHog SDK Replace raw HTTP requests with PostHog SDK for: - Automatic event batching - Built-in retry logic with exponential backoff - Offline support (queues events, sends when back online) - Automatic session tracking - Better device/browser info enrichment Benefits: - More reliable event delivery - Reduced network requests - Cleaner code with less manual state management - Future-proof for feature flags, session replay, etc. Co-authored-by: openhands * refactor: remove redundant hasTrackedFirstUse state in hook The trackFirstUse() function already has built-in deduplication via localStorage, so the local React state was unnecessary. Simplified the hook and added a comment explaining the deduplication mechanism. Co-authored-by: openhands * chore: remove obsolete telemetry dev server The local dev server was used when telemetry used raw HTTP requests to a configurable endpoint. Now that we use the PostHog SDK with the real PostHog endpoint, this is no longer needed. Co-authored-by: openhands * fix: remove trailing comma in package.json Co-authored-by: openhands * fix: address PR review feedback - Make POSTHOG_API_KEY configurable via VITE_POSTHOG_API_KEY env var - Make POSTHOG_HOST configurable via VITE_POSTHOG_HOST env var - Add session deduplication using sessionStorage to prevent duplicate canvas_new_session events from multiple hook instances - Clear sessionStorage in clearTelemetryData() Co-authored-by: openhands * fix: use dynamic imports for PostHog SSR compatibility - Convert top-level posthog-js import to dynamic import for SSR safety - Add getPostHog() lazy loader that only imports in browser context - Make setTelemetryConsent, clearTelemetryData, getPostHogInstance async - Update documentation to clarify default telemetry destination - Update tests for async function signatures This ensures the library works correctly in SSR frameworks (Next.js, Remix, etc.) that might import this module server-side. Co-authored-by: openhands * feat: add telemetry consent banner to app layout The consent banner now appears on all pages until the user explicitly accepts or declines telemetry. This ensures users are always prompted for consent on their first visit regardless of which page they land on. Co-authored-by: openhands * refactor: update telemetry consent banner to modal style - Changed from bottom banner to centered modal overlay (matching OpenHands) - Uses ModalBackdrop, ModalBody, BaseModalTitle, BaseModalDescription - Single checkbox with 'Confirm preferences' button pattern - Full-screen overlay blocks interaction until user makes a choice - Added i18n keys: TELEMETRY$SEND_ANONYMOUS_DATA, TELEMETRY$CONFIRM_PREFERENCES Co-authored-by: openhands * fix: ensure PostHog is initialized before tracking events - Made grantConsent/denyConsent in useTelemetry hook async to ensure PostHog initialization completes before state update triggers tracking - Updated tests for async consent functions - This fixes a race condition where trackFirstUse() could be called before PostHog's opt_in_capturing() had been executed Co-authored-by: openhands --------- Co-authored-by: openhands --- __tests__/hooks/use-telemetry.test.tsx | 123 ++++++ __tests__/services/telemetry.test.ts | 183 +++++++++ .../analytics/telemetry-consent-banner.tsx | 98 +++++ src/hooks/use-telemetry.ts | 104 +++++ src/i18n/translation.json | 119 ++++++ src/lib/index.ts | 15 + src/root.tsx | 2 + src/services/telemetry.ts | 379 ++++++++++++++++++ 8 files changed, 1023 insertions(+) create mode 100644 __tests__/hooks/use-telemetry.test.tsx create mode 100644 __tests__/services/telemetry.test.ts create mode 100644 src/components/features/analytics/telemetry-consent-banner.tsx create mode 100644 src/hooks/use-telemetry.ts create mode 100644 src/services/telemetry.ts 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} +