feat: add PostHog telemetry with privacy-focused tracking

Integrate optional PostHog telemetry to track page views, RDAP queries,
user interactions, and errors while maintaining user privacy.

Key features:
- Type-safe event tracking with discriminated unions
- Automatic source map upload for production error tracking
- Privacy protections (query targets excluded from successful lookups)
- Do Not Track (DNT) header support
- Optional telemetry (disabled by default without environment variables)
- Error boundary with automatic error tracking
- Context-based telemetry integration throughout UI components

Environment variables required for telemetry:
- NEXT_PUBLIC_POSTHOG_KEY: PostHog project API key (client-side)
- NEXT_PUBLIC_POSTHOG_HOST: PostHog API endpoint (client-side)
- POSTHOG_PERSONAL_API_KEY: Source map upload key (server-side)
This commit is contained in:
2025-10-23 18:00:24 -05:00
parent 5fcf9dd94b
commit 5fde7d249f
15 changed files with 858 additions and 17 deletions

View File

@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { CheckIcon, ClipboardIcon } from "@radix-ui/react-icons";
import type { IconButtonProps } from "@radix-ui/themes";
import { IconButton, Tooltip } from "@radix-ui/themes";
import { useTelemetry } from "@/contexts/TelemetryContext";
/**
* Duration in milliseconds for how long the "copied" state persists
@@ -64,6 +65,7 @@ const CopyButton: FunctionComponent<CopyButtonProps> = ({
const [copied, setCopied] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const forceOpenRef = useRef(false);
const { track } = useTelemetry();
// Consolidated timer effect: Reset copied state, tooltip, and force-open flag
useEffect(() => {
@@ -85,12 +87,22 @@ const CopyButton: FunctionComponent<CopyButtonProps> = ({
navigator.clipboard.writeText(value).then(
() => {
setCopied(true);
// Track copy action
track({
name: "user_interaction",
properties: {
action: "copy_button",
component: "CopyButton",
value: value.length, // Track length instead of actual value for privacy
},
});
},
(err) => {
console.error("Failed to copy to clipboard:", err);
}
);
}, [value]);
}, [value, track]);
const handleTooltipOpenChange = useCallback((open: boolean) => {
// Don't allow the tooltip to close if we're in the forced-open period

View File

@@ -0,0 +1,92 @@
/**
* Error boundary component that catches React errors and tracks them via telemetry.
*/
import type { ReactNode, ErrorInfo } from "react";
import { Component } from "react";
import { Box, Flex, Heading, Text, Button } from "@radix-ui/themes";
import { telemetry } from "@/telemetry/client";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: (error: Error) => ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Track the error via telemetry
telemetry.track({
name: "error",
properties: {
errorType: "runtime_error",
message: error.message,
stack: error.stack,
context: {
componentStack: errorInfo.componentStack,
route: typeof window !== "undefined" ? window.location.pathname : "unknown",
},
},
});
// Log to console for debugging
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
render() {
if (this.state.hasError && this.state.error) {
// If custom fallback is provided, use it
if (this.props.fallback) {
return this.props.fallback(this.state.error);
}
// Default fallback UI using Radix components
return (
<Flex direction="column" align="center" justify="center" p="9" gap="4">
<Box>
<Heading size="6" align="center" mb="2">
Something went wrong
</Heading>
<Text align="center" color="gray">
{this.state.error.message}
</Text>
</Box>
<Flex gap="3">
<Button
variant="soft"
onClick={() => {
this.setState({ hasError: false, error: null });
}}
>
Try Again
</Button>
<Button
onClick={() => {
window.location.reload();
}}
>
Reload Page
</Button>
</Flex>
</Flex>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -4,6 +4,7 @@ import { useTheme } from "next-themes";
import { IconButton } from "@radix-ui/themes";
import { MoonIcon, SunIcon, DesktopIcon } from "@radix-ui/react-icons";
import { useEffect, useState, type ReactElement } from "react";
import { useTelemetry } from "@/contexts/TelemetryContext";
type Theme = "light" | "dark" | "system";
@@ -21,6 +22,7 @@ const isTheme = (value: string | undefined): value is Theme => {
export const ThemeToggle = () => {
const { theme, setTheme } = useTheme();
const { track } = useTelemetry();
const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch by only rendering after mount
@@ -36,6 +38,16 @@ export const ThemeToggle = () => {
const { icon, next } = THEME_CONFIG[currentTheme];
const toggleTheme = () => {
// Track theme change
track({
name: "user_interaction",
properties: {
action: "theme_toggle",
component: "ThemeToggle",
value: next,
},
});
setTheme(next);
};