Enable opt-in telemetry

This commit is contained in:
Will Chen
2025-04-21 12:49:54 -07:00
parent 497a3b7dac
commit 16d5320485
18 changed files with 299 additions and 81 deletions

View File

@@ -1,43 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,11 +0,0 @@
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./router";
// The router is automatically initialized by RouterProvider
// so we don't need to call initialize() manually
function App() {
return <RouterProvider router={router} />;
}
export default App;

View File

@@ -21,13 +21,14 @@ import {
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { NodeSystemInfo } from "@/ipc/ipc_types";
import { usePostHog } from "posthog-js/react";
type NodeInstallStep =
| "install"
| "waiting-for-continue"
| "continue-processing";
export function SetupBanner() {
const { capture } = usePostHog();
const navigate = useNavigate();
const { isAnyProviderSetup, loading } = useSettings();
const [nodeSystemInfo, setNodeSystemInfo] = useState<NodeSystemInfo | null>(
@@ -53,6 +54,7 @@ export function SetupBanner() {
}, [checkNode]);
const handleAiSetupClick = () => {
capture("setup-flow:ai-provider-setup-click");
navigate({
to: providerSettingsRoute.id,
params: { provider: "google" },
@@ -60,11 +62,13 @@ export function SetupBanner() {
};
const handleNodeInstallClick = useCallback(async () => {
capture("setup-flow:start-node-install-click");
setNodeInstallStep("waiting-for-continue");
IpcClient.getInstance().openExternalUrl(nodeSystemInfo!.nodeDownloadUrl);
}, [nodeSystemInfo, setNodeInstallStep]);
const finishNodeInstall = useCallback(async () => {
capture("setup-flow:continue-node-install-click");
setNodeInstallStep("continue-processing");
await IpcClient.getInstance().reloadEnvPath();
await checkNode();

View File

@@ -0,0 +1,69 @@
import { IpcClient } from "@/ipc/ipc_client";
import React, { useState } from "react";
import { Button } from "./ui/button";
import { atom, useAtom } from "jotai";
import { useSettings } from "@/hooks/useSettings";
const hideBannerAtom = atom(false);
export function PrivacyBanner() {
const [hideBanner, setHideBanner] = useAtom(hideBannerAtom);
const { settings, updateSettings } = useSettings();
// TODO: Implement state management for banner visibility and user choice
// TODO: Implement functionality for Accept, Reject, Ask me later buttons
// TODO: Add state to hide/show banner based on user choice
if (hideBanner) {
return null;
}
if (settings?.telemetryConsent !== "unset") {
return null;
}
return (
<div className="fixed bg-(--background)/90 bottom-4 right-4 backdrop-blur-md border border-gray-200 dark:border-gray-700 p-4 rounded-lg shadow-lg z-50 max-w-md">
<div className="flex flex-col gap-3">
<div>
<h4 className="text-base font-semibold text-gray-800 dark:text-gray-200">
Share anonymous data?
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Help improve Dyad with anonymous usage data.
<em className="block italic mt-0.5">
Note: this does not log your code or messages.
</em>
<a
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://dyad.sh/docs/telemetry"
);
}}
className="cursor-pointer text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Learn more
</a>
</p>
</div>
<div className="flex gap-2 justify-end">
<Button
variant="default"
onClick={() => {
updateSettings({ telemetryConsent: "opted_in" });
}}
>
Accept
</Button>
<Button
variant="secondary"
onClick={() => {
updateSettings({ telemetryConsent: "opted_out" });
}}
>
Reject
</Button>
<Button variant="ghost" onClick={() => setHideBanner(true)}>
Later
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { showInfo } from "@/lib/toast";
export function TelemetrySwitch() {
const { settings, updateSettings } = useSettings();
return (
<div className="flex items-center space-x-2">
<Switch
id="telemetry-switch"
checked={settings?.telemetryConsent === "opted_in"}
onCheckedChange={() => {
updateSettings({
telemetryConsent:
settings?.telemetryConsent === "opted_in"
? "opted_out"
: "opted_in",
});
}}
/>
<Label htmlFor="telemetry-switch">Telemetry</Label>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import {
} from "@/components/ui/sidebar";
import { ChatList } from "./ChatList";
import { AppList } from "./AppList";
import { usePostHog } from "posthog-js/react";
// Menu items.
const items = [
@@ -123,6 +124,8 @@ function AppIcons({
}: {
onHoverChange: (state: HoverState) => void;
}) {
const { capture } = usePostHog();
const routerState = useRouterState();
const pathname = routerState.location.pathname;

View File

@@ -36,8 +36,9 @@ import type { Message } from "@/ipc/ipc_types";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import { useRunApp } from "@/hooks/useRunApp";
import { AutoApproveSwitch } from "../AutoApproveSwitch";
import { usePostHog } from "posthog-js/react";
export function ChatInput({ chatId }: { chatId?: number }) {
const { capture } = usePostHog();
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { settings, updateSettings, isAnyProviderSetup } = useSettings();
@@ -104,6 +105,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const currentInput = inputValue;
setInputValue("");
await streamMessage({ prompt: currentInput, chatId });
capture("chat:submit");
};
const handleCancel = () => {
@@ -124,6 +126,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
`Approving proposal for chatId: ${chatId}, messageId: ${messageId}`
);
setIsApproving(true);
capture("chat:approve");
try {
const result = await IpcClient.getInstance().approveProposal({
chatId,
@@ -157,6 +160,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
`Rejecting proposal for chatId: ${chatId}, messageId: ${messageId}`
);
setIsRejecting(true);
capture("chat:reject");
try {
const result = await IpcClient.getInstance().rejectProposal({
chatId,

View File

@@ -6,6 +6,7 @@ import { useSettings } from "@/hooks/useSettings";
import { homeChatInputValueAtom } from "@/atoms/chatAtoms"; // Use a different atom for home input
import { useAtom } from "jotai";
import { useStreamChat } from "@/hooks/useStreamChat";
import { usePostHog } from "posthog-js/react";
export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
@@ -14,7 +15,6 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
const { streamMessage, isStreaming, setIsStreaming } = useStreamChat({
hasChatId: false,
}); // eslint-disable-line @typescript-eslint/no-unused-vars
const adjustHeight = () => {
const textarea = textareaRef.current;
if (textarea) {

View File

@@ -13,6 +13,17 @@ const PROVIDER_TO_ENV_VAR: Record<string, string> = {
// Define a type for the environment variables we expect
type EnvVars = Record<string, string | undefined>;
const TELEMETRY_CONSENT_KEY = "dyadTelemetryConsent";
const TELEMETRY_USER_ID_KEY = "dyadTelemetryUserId";
export function isTelemetryOptedIn() {
return window.localStorage.getItem(TELEMETRY_CONSENT_KEY) === "opted_in";
}
export function getTelemetryUserId(): string | null {
return window.localStorage.getItem(TELEMETRY_USER_ID_KEY);
}
export function useSettings() {
const [settings, setSettingsAtom] = useAtom(userSettingsAtom);
const [envVars, setEnvVarsAtom] = useAtom(envVarsAtom);
@@ -49,6 +60,23 @@ export function useSettings() {
const ipcClient = IpcClient.getInstance();
const updatedSettings = await ipcClient.setUserSettings(newSettings);
setSettingsAtom(updatedSettings);
if (updatedSettings.telemetryConsent) {
window.localStorage.setItem(
TELEMETRY_CONSENT_KEY,
updatedSettings.telemetryConsent
);
} else {
window.localStorage.removeItem(TELEMETRY_CONSENT_KEY);
}
if (updatedSettings.telemetryUserId) {
window.localStorage.setItem(
TELEMETRY_USER_ID_KEY,
updatedSettings.telemetryUserId
);
} else {
window.localStorage.removeItem(TELEMETRY_USER_ID_KEY);
}
setError(null);
return updatedSettings;
} catch (error) {

View File

@@ -89,6 +89,8 @@ export const UserSettingsSchema = z.object({
githubUser: GithubUserSchema.optional(),
githubAccessToken: SecretSchema.optional(),
autoApproveChanges: z.boolean().optional(),
telemetryConsent: z.enum(["opted_in", "opted_out", "unset"]).optional(),
telemetryUserId: z.string().optional(),
// DEPRECATED.
runtimeMode: RuntimeModeSchema.optional(),
});

View File

@@ -1,15 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./router";
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Toaster } from "sonner";
import "./styles/globals.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<TooltipProvider>
<RouterProvider router={router} />
</TooltipProvider>
</React.StrictMode>
);

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { getUserDataPath } from "../paths/paths";
import { UserSettingsSchema, type UserSettings, Secret } from "../lib/schemas";
import { safeStorage } from "electron";
import { v4 as uuidv4 } from "uuid";
// IF YOU NEED TO UPDATE THIS, YOU'RE PROBABLY DOING SOMETHING WRONG!
// Need to maintain backwards compatibility!
@@ -12,6 +13,8 @@ const DEFAULT_SETTINGS: UserSettings = {
provider: "auto",
},
providerSettings: {},
telemetryConsent: "unset",
telemetryUserId: uuidv4(),
};
const SETTINGS_FILE = "user-settings.json";

View File

@@ -11,6 +11,9 @@ import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import { useState, useEffect } from "react";
import { useStreamChat } from "@/hooks/useStreamChat";
import { HomeChatInput } from "@/components/chat/HomeChatInput";
import { usePostHog } from "posthog-js/react";
import { PrivacyBanner } from "@/components/TelemetryBanner";
export default function HomePage() {
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
const navigate = useNavigate();
@@ -21,7 +24,7 @@ export default function HomePage() {
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const [isLoading, setIsLoading] = useState(false);
const { streamMessage } = useStreamChat({ hasChatId: false });
const { capture } = usePostHog();
// Get the appId from search params
const appId = search.appId ? Number(search.appId) : null;
@@ -50,6 +53,7 @@ export default function HomePage() {
setSelectedAppId(result.app.id);
setIsPreviewOpen(false);
await refreshApps(); // Ensure refreshApps is awaited if it's async
capture("home:chat-submit");
navigate({ to: "/chat", search: { id: result.chatId } });
} catch (error) {
console.error("Failed to create chat:", error);
@@ -173,6 +177,7 @@ export default function HomePage() {
))}
</div>
</div>
<PrivacyBanner />
</div>
);
}

View File

@@ -5,12 +5,14 @@ import ConfirmationDialog from "@/components/ConfirmationDialog";
import { IpcClient } from "@/ipc/ipc_client";
import { showSuccess, showError } from "@/lib/toast";
import { AutoApproveSwitch } from "@/components/AutoApproveSwitch";
import { TelemetrySwitch } from "@/components/TelemetrySwitch";
import { useSettings } from "@/hooks/useSettings";
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [appVersion, setAppVersion] = useState<string | null>(null);
const { settings } = useSettings();
useEffect(() => {
// Fetch app version
@@ -103,6 +105,27 @@ export default function SettingsPage() {
</div>
</div>
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
Telemetry
</h2>
<div className="space-y-2">
<TelemetrySwitch />
<div className="text-sm text-gray-500 dark:text-gray-400">
This records anonymous usage data to improve the product.
</div>
</div>
<div className="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400">
<span className="mr-2 font-medium">Telemetry ID:</span>
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-gray-800 dark:text-gray-200 font-mono">
{settings ? settings.telemetryUserId : "n/a"}
</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<ProviderSettingsGrid configuredProviders={[]} />
</div>

View File

@@ -1,9 +1,70 @@
import { StrictMode } from "react";
import { StrictMode, useEffect } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { router } from "./router";
import { RouterProvider } from "@tanstack/react-router";
import { PostHogProvider } from "posthog-js/react";
import posthog from "posthog-js";
import { getTelemetryUserId, isTelemetryOptedIn } from "./hooks/useSettings";
const posthogClient = posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
api_host: "https://us.i.posthog.com",
debug: import.meta.env.MODE === "development",
autocapture: false,
capture_pageview: false,
before_send: (event) => {
if (!isTelemetryOptedIn()) {
console.debug("Telemetry not opted in, skipping event", event);
return null;
}
const telemetryUserId = getTelemetryUserId();
if (telemetryUserId) {
posthogClient.identify(telemetryUserId);
}
if (event?.properties["$ip"]) {
event.properties["$ip"] = null;
}
console.debug(
"Telemetry opted in - UUID:",
telemetryUserId,
"sending event",
event
);
return event;
},
persistence: "localStorage",
});
function App() {
useEffect(() => {
// Subscribe to navigation state changes
const unsubscribe = router.subscribe("onResolved", (navigation) => {
// Capture the navigation event in PostHog
posthog.capture("navigation", {
toPath: navigation.toLocation.pathname,
fromPath: navigation.fromLocation?.pathname,
});
// Optionally capture as a standard pageview as well
posthog.capture("$pageview", {
path: navigation.toLocation.pathname,
});
});
// Clean up subscription when component unmounts
return () => {
unsubscribe();
};
}, []);
return <RouterProvider router={router} />;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<PostHogProvider client={posthogClient}>
<App />
</PostHogProvider>
</StrictMode>
);