| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { BootstrapConfig, CaptureResult, PostHog } from "posthog-js"; |
| import { getLockedCloudAuthMode } from "#/api/agent-server-config"; |
| import defaults from "../../config/defaults.json"; |
|
|
| import packageJson from "../../package.json"; |
| import { |
| AGENT_CANVAS_CLIENT_SOURCE, |
| AGENT_CANVAS_CLIENT_VERSION, |
| } from "#/api/client-source"; |
| import { |
| getBackendTelemetryProperties, |
| getCloudTelemetryProperties, |
| type BackendTelemetryContextInput, |
| type CloudTelemetryContextInput, |
| } from "#/services/telemetry-context"; |
|
|
| const TELEMETRY_CONSENT_KEY = "openhands-telemetry-consent"; |
| const TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY = |
| "openhands-telemetry-consent-pending-cloud-sync"; |
| const TELEMETRY_CONSENT_PENDING_LOCAL_REVOCATION_KEY = |
| "openhands-telemetry-consent-pending-local-revocation"; |
| const TELEMETRY_CONSENT_CHANGE_EVENT = "openhands-telemetry-consent-change"; |
| const TELEMETRY_FIRST_USE_KEY = "openhands-telemetry-first-use"; |
| const TELEMETRY_SESSION_KEY = "openhands-telemetry-session"; |
| const POSTHOG_INSTANCE_NAME = "agent-canvas"; |
| const POSTHOG_PAGEVIEW_CAPTURE_MODE = "history_change"; |
|
|
| |
| |
| const DEFAULT_POSTHOG_API_KEY: string = |
| (import.meta.env.VITE_POSTHOG_API_KEY as string | undefined) || |
| defaults.telemetry.posthogApiKey; |
|
|
| |
| |
| |
| const DEFAULT_POSTHOG_HOST = |
| import.meta.env.VITE_POSTHOG_HOST || "https://z.openhands.dev"; |
|
|
| |
| |
| const DEFAULT_POSTHOG_UI_HOST = |
| import.meta.env.VITE_POSTHOG_UI_HOST || "https://us.posthog.com"; |
|
|
| export interface TelemetryConfig { |
| |
| apiKey?: string; |
| |
| apiHost?: string; |
| |
| uiHost?: string; |
| } |
|
|
| export type TelemetryConfiguration = TelemetryConfig | false; |
|
|
| export type TelemetryConsent = "granted" | "denied" | "pending"; |
| export type ResolvedTelemetryConsent = Exclude<TelemetryConsent, "pending">; |
|
|
| export interface SetTelemetryConsentOptions { |
| |
| syncToCloud?: boolean; |
| } |
|
|
| let posthogInstance: PostHog | null = null; |
| let initializationPromise: Promise<PostHog | null> | null = null; |
| let pendingBootstrap: BootstrapConfig | undefined; |
| let telemetryConfig: TelemetryConfig = {}; |
| let telemetryDisabled = false; |
|
|
| |
| function isRuntimeDoNotTrackEnabled(): boolean { |
| return ( |
| typeof window !== "undefined" && |
| (window as unknown as Record<string, unknown>) |
| .__AGENT_CANVAS_DO_NOT_TRACK__ === true |
| ); |
| } |
|
|
| |
| function isTelemetryHardDisabled(): boolean { |
| return telemetryDisabled || isRuntimeDoNotTrackEnabled(); |
| } |
|
|
| interface TelemetryIdentity { |
| distinctId: string; |
| properties: Record<string, unknown>; |
| } |
|
|
| |
| |
| |
| let desiredTelemetryIdentity: TelemetryIdentity | null | undefined; |
| let desiredIdentityRevision = 0; |
| let appliedIdentityRevision = -1; |
|
|
| const CANVAS_EVENT_PROPERTIES = Object.freeze({ |
| client_source: AGENT_CANVAS_CLIENT_SOURCE, |
| client_version: AGENT_CANVAS_CLIENT_VERSION, |
| package_name: packageJson.name, |
| package_version: packageJson.version, |
| }); |
|
|
| let telemetryBackendContext = getBackendTelemetryProperties({}); |
| let telemetryCloudContext = getCloudTelemetryProperties(); |
|
|
| export function setTelemetryBackendContext( |
| context: BackendTelemetryContextInput, |
| ): void { |
| telemetryBackendContext = getBackendTelemetryProperties(context); |
| } |
|
|
| export function setTelemetryCloudContext( |
| context: CloudTelemetryContextInput | null, |
| ): void { |
| telemetryCloudContext = getCloudTelemetryProperties(context); |
| } |
|
|
| function addCanvasEventProperties( |
| event: CaptureResult | null, |
| ): CaptureResult | null { |
| if (!event) return null; |
|
|
| return { |
| ...event, |
| properties: { |
| ...telemetryBackendContext, |
| ...telemetryCloudContext, |
| ...event.properties, |
| ...CANVAS_EVENT_PROPERTIES, |
| }, |
| }; |
| } |
|
|
| function restorePostHogConsent(posthog: PostHog): void { |
| if (telemetryDisabled || getTelemetryConsent() !== "granted") { |
| posthog.opt_out_capturing(); |
| } else { |
| posthog.opt_in_capturing(); |
| } |
| } |
|
|
| function resetPostHogIdentity(posthog: PostHog, resetDeviceId = false): void { |
| posthog.reset(resetDeviceId); |
| appliedIdentityRevision = -1; |
| |
| |
| restorePostHogConsent(posthog); |
| } |
|
|
| function applyDesiredTelemetryIdentity(posthog: PostHog): void { |
| if (desiredTelemetryIdentity === undefined || !isTelemetryEnabled()) return; |
|
|
| const desiredId = desiredTelemetryIdentity?.distinctId; |
| const currentId = posthog.get_property("$user_id"); |
| if (currentId != null && currentId !== desiredId) { |
| resetPostHogIdentity(posthog); |
| } |
|
|
| if (desiredTelemetryIdentity === null) { |
| appliedIdentityRevision = desiredIdentityRevision; |
| return; |
| } |
|
|
| if ( |
| posthog.get_property("$user_id") !== desiredTelemetryIdentity.distinctId || |
| appliedIdentityRevision !== desiredIdentityRevision |
| ) { |
| posthog.identify( |
| desiredTelemetryIdentity.distinctId, |
| desiredTelemetryIdentity.properties, |
| ); |
| appliedIdentityRevision = desiredIdentityRevision; |
| } |
| } |
|
|
| function propertiesEqual( |
| left: Record<string, unknown>, |
| right: Record<string, unknown>, |
| ): boolean { |
| const keys = Object.keys(left); |
| return ( |
| keys.length === Object.keys(right).length && |
| keys.every((key) => left[key] === right[key]) |
| ); |
| } |
|
|
| |
| |
| |
| |
| export function configureTelemetry(config: TelemetryConfiguration): void { |
| if (config === false) { |
| if (telemetryDisabled) return; |
| telemetryDisabled = true; |
| posthogInstance?.opt_out_capturing(); |
| notifyTelemetryConsentListeners(); |
| return; |
| } |
|
|
| const wasDisabled = telemetryDisabled; |
| telemetryDisabled = false; |
|
|
| if (!posthogInstance && !initializationPromise) { |
| const definedConfig = Object.fromEntries( |
| Object.entries(config).filter(([, value]) => value !== undefined), |
| ) as TelemetryConfig; |
| telemetryConfig = { ...telemetryConfig, ...definedConfig }; |
| } |
|
|
| if (wasDisabled) { |
| if (posthogInstance) { |
| restorePostHogConsent(posthogInstance); |
| applyDesiredTelemetryIdentity(posthogInstance); |
| } |
| notifyTelemetryConsentListeners(); |
| } |
| } |
|
|
| function getResolvedTelemetryConfig(): Required<TelemetryConfig> | null { |
| if (isTelemetryHardDisabled()) return null; |
|
|
| return { |
| apiKey: telemetryConfig.apiKey || DEFAULT_POSTHOG_API_KEY, |
| apiHost: telemetryConfig.apiHost || DEFAULT_POSTHOG_HOST, |
| uiHost: telemetryConfig.uiHost || DEFAULT_POSTHOG_UI_HOST, |
| }; |
| } |
|
|
| |
| |
| |
| function isBrowser(): boolean { |
| return typeof window !== "undefined" && typeof localStorage !== "undefined"; |
| } |
|
|
| |
| |
| |
| |
| async function getPostHog(): Promise<PostHog | null> { |
| if (!isBrowser()) { |
| return null; |
| } |
|
|
| if (posthogInstance) { |
| return posthogInstance; |
| } |
|
|
| try { |
| const { default: posthog } = await import("posthog-js"); |
| return posthog; |
| } catch { |
| |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| function isDoNotTrackEnabled(): boolean { |
| if (telemetryDisabled) { |
| return true; |
| } |
|
|
| |
| if ( |
| typeof import.meta !== "undefined" && |
| import.meta.env?.VITE_DO_NOT_TRACK === "1" |
| ) { |
| return true; |
| } |
|
|
| |
| if (isRuntimeDoNotTrackEnabled()) { |
| return true; |
| } |
|
|
| |
| if (typeof process !== "undefined" && process.env?.DO_NOT_TRACK === "1") { |
| return true; |
| } |
|
|
| |
| if ( |
| typeof navigator !== "undefined" && |
| (navigator.doNotTrack === "1" || |
| |
| (typeof window !== "undefined" && window.doNotTrack === "1")) |
| ) { |
| return true; |
| } |
|
|
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function configurePostHogBootstrap( |
| bootstrap: BootstrapConfig | undefined, |
| ): void { |
| if (!posthogInstance) { |
| pendingBootstrap = bootstrap; |
| } |
| } |
|
|
| export async function initializePostHogClient( |
| enableCapturing = false, |
| ): Promise<PostHog | null> { |
| if (posthogInstance) { |
| return posthogInstance; |
| } |
|
|
| if (initializationPromise) { |
| return initializationPromise; |
| } |
|
|
| initializationPromise = (async () => { |
| const config = getResolvedTelemetryConfig(); |
| if (!config) { |
| return null; |
| } |
|
|
| const posthog = await getPostHog(); |
| if (!posthog) { |
| return null; |
| } |
|
|
| |
| |
| const initializedPostHog = posthog.init( |
| config.apiKey, |
| { |
| api_host: config.apiHost, |
| ui_host: config.uiHost, |
| opt_out_capturing_by_default: !enableCapturing, |
| persistence: "localStorage", |
| persistence_name: POSTHOG_INSTANCE_NAME, |
| consent_persistence_name: `${POSTHOG_INSTANCE_NAME}-consent`, |
| person_profiles: "always", |
| capture_pageview: POSTHOG_PAGEVIEW_CAPTURE_MODE, |
| autocapture: true, |
| disable_session_recording: true, |
| bootstrap: pendingBootstrap, |
| before_send: addCanvasEventProperties, |
| }, |
| POSTHOG_INSTANCE_NAME, |
| ); |
| if (!initializedPostHog) return null; |
|
|
| posthogInstance = initializedPostHog; |
| pendingBootstrap = undefined; |
|
|
| if (telemetryDisabled) { |
| posthogInstance.opt_out_capturing(); |
| } else if (getTelemetryConsent() === "granted") { |
| posthogInstance.opt_in_capturing(); |
| applyDesiredTelemetryIdentity(posthogInstance); |
| } else if (!enableCapturing) { |
| posthogInstance.opt_out_capturing(); |
| } |
|
|
| return posthogInstance; |
| })(); |
|
|
| try { |
| return await initializationPromise; |
| } finally { |
| if (!posthogInstance) { |
| initializationPromise = null; |
| } |
| } |
| } |
|
|
| |
| |
| |
| export function getTelemetryConsent(): TelemetryConsent { |
| if (!isBrowser()) { |
| return "pending"; |
| } |
|
|
| |
| if (isDoNotTrackEnabled()) { |
| return "denied"; |
| } |
|
|
| if (getLockedCloudAuthMode() === "cookie") { |
| return "granted"; |
| } |
|
|
| try { |
| const consent = localStorage.getItem(TELEMETRY_CONSENT_KEY); |
| if (consent === "granted" || consent === "denied") { |
| return consent; |
| } |
| } catch { |
| |
| } |
|
|
| return "pending"; |
| } |
|
|
| |
| |
| |
| |
| |
| export function getPendingCloudTelemetryConsent(): ResolvedTelemetryConsent | null { |
| if (!isBrowser()) return null; |
|
|
| try { |
| const consent = localStorage.getItem( |
| TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY, |
| ); |
| return consent === "granted" || consent === "denied" ? consent : null; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| export function getPendingLocalTelemetryRevocationId(): string | null { |
| if (!isBrowser()) return null; |
|
|
| try { |
| return localStorage.getItem(TELEMETRY_CONSENT_PENDING_LOCAL_REVOCATION_KEY); |
| } catch { |
| return null; |
| } |
| } |
|
|
| export function subscribeTelemetryConsent(listener: () => void): () => void { |
| if (!isBrowser()) return () => {}; |
| const handleStorage = (event: StorageEvent) => { |
| if ( |
| event.key === TELEMETRY_CONSENT_KEY || |
| event.key === TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY |
| ) { |
| listener(); |
| } |
| }; |
| window.addEventListener(TELEMETRY_CONSENT_CHANGE_EVENT, listener); |
| window.addEventListener("storage", handleStorage); |
|
|
| return () => { |
| window.removeEventListener(TELEMETRY_CONSENT_CHANGE_EVENT, listener); |
| window.removeEventListener("storage", handleStorage); |
| }; |
| } |
|
|
| function notifyTelemetryConsentListeners(): void { |
| if (isBrowser()) |
| window.dispatchEvent(new Event(TELEMETRY_CONSENT_CHANGE_EVENT)); |
| } |
|
|
| function markTelemetryConsentForCloudSync( |
| consent: ResolvedTelemetryConsent, |
| ): void { |
| if (!isBrowser()) return; |
|
|
| try { |
| localStorage.setItem(TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY, consent); |
| } catch { |
| |
| } |
| } |
|
|
| function markPendingLocalTelemetryRevocation(distinctId: string | null): void { |
| if (!isBrowser() || !distinctId) return; |
|
|
| try { |
| localStorage.setItem( |
| TELEMETRY_CONSENT_PENDING_LOCAL_REVOCATION_KEY, |
| distinctId, |
| ); |
| } catch { |
| |
| } |
| } |
|
|
| export function clearPendingLocalTelemetryRevocation( |
| expectedDistinctId: string, |
| ): void { |
| if (!isBrowser()) return; |
|
|
| try { |
| if ( |
| localStorage.getItem(TELEMETRY_CONSENT_PENDING_LOCAL_REVOCATION_KEY) !== |
| expectedDistinctId |
| ) { |
| return; |
| } |
| localStorage.removeItem(TELEMETRY_CONSENT_PENDING_LOCAL_REVOCATION_KEY); |
| } catch { |
| |
| } |
| } |
|
|
| export function clearPendingCloudTelemetryConsent( |
| expected?: ResolvedTelemetryConsent, |
| ): void { |
| if (!isBrowser()) return; |
|
|
| try { |
| if ( |
| expected !== undefined && |
| localStorage.getItem(TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY) !== |
| expected |
| ) { |
| return; |
| } |
| localStorage.removeItem(TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY); |
| notifyTelemetryConsentListeners(); |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
| export async function setTelemetryConsent( |
| consent: ResolvedTelemetryConsent, |
| { syncToCloud = true }: SetTelemetryConsentOptions = {}, |
| ): Promise<void> { |
| if (!isBrowser()) { |
| return; |
| } |
|
|
| try { |
| const previousConsent = getTelemetryConsent(); |
| const unchanged = previousConsent === consent; |
| localStorage.setItem(TELEMETRY_CONSENT_KEY, consent); |
| if (isTelemetryHardDisabled()) return; |
| if (unchanged) return; |
|
|
| |
| |
| const posthog = posthogInstance ?? (await initializePostHogClient()); |
| if (!posthog) { |
| return; |
| } |
|
|
| if (consent === "granted") { |
| posthog.opt_in_capturing(); |
| applyDesiredTelemetryIdentity(posthog); |
| } else { |
| markPendingLocalTelemetryRevocation(posthog.get_distinct_id?.() ?? null); |
| if (posthog.get_property?.("$user_id") != null) { |
| resetPostHogIdentity(posthog); |
| } else { |
| posthog.opt_out_capturing(); |
| } |
| } |
| } catch { |
| |
| } finally { |
| |
| |
| |
| if (syncToCloud) { |
| markTelemetryConsentForCloudSync(consent); |
| } |
| notifyTelemetryConsentListeners(); |
| } |
| } |
|
|
| |
| |
| |
| |
| export async function setTelemetryIdentity( |
| distinctId: string | null, |
| properties: Record<string, unknown> = {}, |
| ): Promise<void> { |
| const nextIdentity = distinctId === null ? null : { distinctId, properties }; |
| const unchanged = |
| desiredTelemetryIdentity === nextIdentity || |
| (desiredTelemetryIdentity !== undefined && |
| desiredTelemetryIdentity !== null && |
| nextIdentity !== null && |
| desiredTelemetryIdentity.distinctId === nextIdentity.distinctId && |
| propertiesEqual(desiredTelemetryIdentity.properties, properties)); |
| if (unchanged) return; |
|
|
| desiredTelemetryIdentity = nextIdentity; |
| desiredIdentityRevision += 1; |
| appliedIdentityRevision = -1; |
|
|
| if (!isTelemetryEnabled()) return; |
| const posthog = posthogInstance ?? (await initializePostHogClient()); |
| if (posthog && isTelemetryEnabled()) { |
| |
| |
| applyDesiredTelemetryIdentity(posthog); |
| } |
| } |
|
|
| |
| |
| |
| export function isTelemetryEnabled(): boolean { |
| return getTelemetryConsent() === "granted"; |
| } |
|
|
| |
| |
| |
| function hasFirstUseSent(): boolean { |
| if (!isBrowser()) { |
| return false; |
| } |
|
|
| try { |
| return localStorage.getItem(TELEMETRY_FIRST_USE_KEY) === "true"; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| function markFirstUseSent(): void { |
| if (!isBrowser()) { |
| return; |
| } |
|
|
| try { |
| localStorage.setItem(TELEMETRY_FIRST_USE_KEY, "true"); |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function trackInstall(): Promise<void> { |
| |
| if (isDoNotTrackEnabled()) { |
| return; |
| } |
|
|
| |
| if (hasFirstUseSent()) { |
| return; |
| } |
|
|
| |
| const posthog = await initializePostHogClient(true); |
| if (!posthog || isDoNotTrackEnabled()) { |
| return; |
| } |
|
|
| |
| const wasOptedOut = posthog.has_opted_out_capturing?.() ?? false; |
| if (wasOptedOut) { |
| posthog.opt_in_capturing(); |
| } |
|
|
| |
| 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, |
| }); |
|
|
| |
| markFirstUseSent(); |
|
|
| |
| |
| const currentConsent = getTelemetryConsent(); |
| if (currentConsent !== "granted") { |
| posthog.opt_out_capturing(); |
| } |
| } |
|
|
| |
| |
| |
| function hasSessionSent(): boolean { |
| if (!isBrowser()) { |
| return false; |
| } |
|
|
| try { |
| return sessionStorage.getItem(TELEMETRY_SESSION_KEY) === "true"; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| function markSessionSent(): void { |
| if (!isBrowser()) { |
| return; |
| } |
|
|
| try { |
| sessionStorage.setItem(TELEMETRY_SESSION_KEY, "true"); |
| } catch { |
| |
| } |
| } |
|
|
| |
| async function getPostHogForConsentedCapture(): Promise<PostHog | null> { |
| if (!isTelemetryEnabled() || getTelemetryConsent() !== "granted") return null; |
|
|
| const posthog = await initializePostHogClient(); |
| if (!posthog || !isTelemetryEnabled()) return null; |
|
|
| |
| |
| |
| |
| |
| if (posthog.has_opted_out_capturing?.()) { |
| posthog.opt_in_capturing(); |
| } |
|
|
| applyDesiredTelemetryIdentity(posthog); |
|
|
| return posthog; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function trackSessionStart(): Promise<void> { |
| |
| if (hasSessionSent()) { |
| return; |
| } |
|
|
| const posthog = await getPostHogForConsentedCapture(); |
| if (!posthog) return; |
|
|
| posthog.capture("canvas_new_session", { |
| is_first_use: !hasFirstUseSent(), |
| }); |
|
|
| |
| markSessionSent(); |
| } |
|
|
| |
| export async function getTelemetryDistinctId(): Promise<string | null> { |
| const posthog = await getPostHogForConsentedCapture(); |
| return posthog?.get_distinct_id?.() ?? null; |
| } |
|
|
| |
| export async function getTelemetryDistinctIdForConsentSync(): Promise< |
| string | null |
| > { |
| if (!isBrowser()) return null; |
|
|
| if (getTelemetryConsent() !== "granted") { |
| const pendingRevocationId = getPendingLocalTelemetryRevocationId(); |
| if (pendingRevocationId) return pendingRevocationId; |
| } |
|
|
| if (telemetryDisabled || isDoNotTrackEnabled()) return null; |
|
|
| const posthog = await initializePostHogClient(); |
| return posthog?.get_distinct_id?.() ?? null; |
| } |
|
|
| |
| |
| |
| export async function trackEvent( |
| eventName: string, |
| properties: Record<string, unknown> = {}, |
| ): Promise<void> { |
| const posthog = await getPostHogForConsentedCapture(); |
| if (!posthog) return; |
|
|
| posthog.capture(eventName, properties); |
| } |
|
|
| |
| export async function trackException( |
| error: unknown, |
| properties: Record<string, unknown> = {}, |
| ): Promise<void> { |
| const posthog = await getPostHogForConsentedCapture(); |
| if (!posthog) return; |
|
|
| posthog.captureException(error, properties); |
| } |
|
|
| |
| |
| |
| export async function clearTelemetryData(): Promise<void> { |
| if (!isBrowser()) { |
| return; |
| } |
|
|
| try { |
| markPendingLocalTelemetryRevocation( |
| posthogInstance?.get_distinct_id?.() ?? null, |
| ); |
| localStorage.removeItem(TELEMETRY_CONSENT_KEY); |
| localStorage.removeItem(TELEMETRY_FIRST_USE_KEY); |
| } catch { |
| |
| } |
| clearPendingCloudTelemetryConsent(); |
| try { |
| sessionStorage.removeItem(TELEMETRY_SESSION_KEY); |
| } catch { |
| |
| } |
|
|
| telemetryBackendContext = getBackendTelemetryProperties({}); |
| telemetryCloudContext = getCloudTelemetryProperties(); |
| desiredTelemetryIdentity = null; |
| desiredIdentityRevision += 1; |
| appliedIdentityRevision = -1; |
|
|
| try { |
| if (posthogInstance) { |
| resetPostHogIdentity(posthogInstance, true); |
| } |
| } catch { |
| |
| try { |
| posthogInstance?.opt_out_capturing(); |
| } catch { |
| |
| } |
| } finally { |
| notifyTelemetryConsentListeners(); |
| } |
| } |
|
|