mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 13:38:55 +08:00
feat: add first-use telemetry tracking with consent (#138)
* 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * chore: use single production PostHog API key everywhere Simplify by using the same API key for all environments. Co-authored-by: openhands <openhands@all-hands.dev> * chore: rename telemetry events - library_first_use → canvas_install - library_session_start → canvas_new_session Co-authored-by: openhands <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * fix: remove trailing comma in package.json Co-authored-by: openhands <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> --------- Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
committed by
GitHub
co-authored by
openhands
parent
dc0b5ac964
commit
e2615344df
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
* <>
|
||||
* <TelemetryConsentBanner />
|
||||
* <MainContent />
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function TelemetryConsentBanner({
|
||||
onChoice,
|
||||
}: TelemetryConsentBannerProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { showConsentPrompt, grantConsent, denyConsent } = useTelemetry();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<ModalBackdrop aria-label={t(I18nKey.TELEMETRY$CONSENT_TITLE)}>
|
||||
<form
|
||||
data-testid="telemetry-consent-form"
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<BaseModalTitle title={t(I18nKey.TELEMETRY$CONSENT_TITLE)} />
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.TELEMETRY$CONSENT_DESCRIPTION)}
|
||||
</BaseModalDescription>
|
||||
|
||||
<label className="flex gap-2 items-center self-start text-sm cursor-pointer">
|
||||
<input
|
||||
name="analytics"
|
||||
type="checkbox"
|
||||
defaultChecked
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
{t(I18nKey.TELEMETRY$SEND_ANONYMOUS_DATA)}
|
||||
</label>
|
||||
|
||||
<BrandButton
|
||||
testId="confirm-telemetry-preferences"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
>
|
||||
{t(I18nKey.TELEMETRY$CONFIRM_PREFERENCES)}
|
||||
</BrandButton>
|
||||
</ModalBody>
|
||||
</form>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>) => 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 <ConsentBanner onAccept={grantConsent} onDecline={denyConsent} />;
|
||||
* }
|
||||
*
|
||||
* return <div>...</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useTelemetry(): UseTelemetryReturn {
|
||||
const [consent, setConsentState] = useState<TelemetryConsent>(() =>
|
||||
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<string, unknown>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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": "Підтвердити налаштування"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }) {
|
||||
<AgentServerUIRoot contentClassName="min-h-screen">
|
||||
{children}
|
||||
<Toaster />
|
||||
<TelemetryConsentBanner />
|
||||
<div id="modal-portal-exit" />
|
||||
</AgentServerUIRoot>
|
||||
<ScrollRestoration />
|
||||
|
||||
@@ -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<PostHog | null> {
|
||||
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<PostHog | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<PostHog | null> {
|
||||
if (!isInitialized) {
|
||||
return null;
|
||||
}
|
||||
return posthogInstance;
|
||||
}
|
||||
Reference in New Issue
Block a user