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);
};

View File

@@ -1,5 +1,6 @@
import type { FunctionComponent, ReactNode } from "react";
import { createContext, useContext, useState, useEffect, useCallback } from "react";
import { useTelemetry } from "@/telemetry";
type DateFormat = "relative" | "absolute";
@@ -13,6 +14,8 @@ const DateFormatContext = createContext<DateFormatContextType | undefined>(undef
const STORAGE_KEY = "global-date-format-preference";
export const DateFormatProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => {
const { track } = useTelemetry();
const [format, setFormat] = useState<DateFormat>(() => {
// Initialize from localStorage on client side
if (typeof window !== "undefined") {
@@ -30,8 +33,22 @@ export const DateFormatProvider: FunctionComponent<{ children: ReactNode }> = ({
}, [format]);
const toggleFormat = useCallback(() => {
setFormat((current) => (current === "relative" ? "absolute" : "relative"));
}, []);
setFormat((current) => {
const newFormat = current === "relative" ? "absolute" : "relative";
// Track date format change
track({
name: "user_interaction",
properties: {
action: "date_format_change",
component: "DateFormatProvider",
value: newFormat,
},
});
return newFormat;
});
}, [track]);
return (
<DateFormatContext.Provider value={{ format, toggleFormat }}>

View File

@@ -0,0 +1,76 @@
/**
* Telemetry context provider for PostHog integration.
* Provides useTelemetry hook and automatic page view tracking.
*/
import { createContext, useContext, useEffect, type ReactNode } from "react";
import { useRouter } from "next/router";
import { telemetry } from "@/telemetry/client";
import type { TelemetryEvent } from "@/telemetry/events";
interface TelemetryContextValue {
track: <E extends TelemetryEvent>(event: E) => void;
identify: (userId: string, properties?: Record<string, unknown>) => void;
reset: () => void;
isEnabled: () => boolean;
}
const TelemetryContext = createContext<TelemetryContextValue | null>(null);
interface TelemetryProviderProps {
children: ReactNode;
}
export function TelemetryProvider({ children }: TelemetryProviderProps) {
const router = useRouter();
// Initialize telemetry on mount
useEffect(() => {
telemetry.init();
}, []);
// Track page views on route change
useEffect(() => {
const handleRouteChange = (url: string) => {
telemetry.track({
name: "page_view",
properties: {
route: url,
referrer: document.referrer,
},
});
};
// Track initial page view
handleRouteChange(router.asPath);
// Subscribe to route changes
// Note: routeChangeComplete only fires for subsequent navigations, not the initial load,
// so the initial page view tracked above won't be duplicated
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}, [router.asPath, router.events]);
const value: TelemetryContextValue = {
track: telemetry.track.bind(telemetry),
identify: telemetry.identify.bind(telemetry),
reset: telemetry.reset.bind(telemetry),
isEnabled: telemetry.isEnabled.bind(telemetry),
};
return <TelemetryContext.Provider value={value}>{children}</TelemetryContext.Provider>;
}
/**
* Hook to access telemetry client from any component
*/
export function useTelemetry(): TelemetryContextValue {
const context = useContext(TelemetryContext);
if (!context) {
throw new Error("useTelemetry must be used within a TelemetryProvider");
}
return context;
}

7
src/env/schema.mjs vendored
View File

@@ -7,6 +7,8 @@ import { z } from "zod";
*/
export const serverSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
// PostHog source map upload configuration (optional, only needed for production builds)
POSTHOG_PERSONAL_API_KEY: z.string().optional(),
});
/**
@@ -16,6 +18,7 @@ export const serverSchema = z.object({
*/
export const serverEnv = {
NODE_ENV: process.env.NODE_ENV,
POSTHOG_PERSONAL_API_KEY: process.env.POSTHOG_PERSONAL_API_KEY,
};
/**
@@ -25,6 +28,8 @@ export const serverEnv = {
*/
export const clientSchema = z.object({
// NEXT_PUBLIC_CLIENTVAR: z.string(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
});
/**
@@ -35,4 +40,6 @@ export const clientSchema = z.object({
*/
export const clientEnv = {
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
};

View File

@@ -9,21 +9,27 @@ import "overlayscrollbars/overlayscrollbars.css";
import "@/styles/globals.css";
import { DateFormatProvider } from "@/contexts/DateFormatContext";
import { TelemetryProvider } from "@/contexts/TelemetryContext";
import ErrorBoundary from "@/components/ErrorBoundary";
const MyApp: AppType = ({ Component, pageProps }) => {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
// Cloudflare Rocket Loader breaks the script injection and causes theme flashing
scriptProps={{ "data-cfasync": "false" }}
>
<Theme accentColor="indigo" grayColor="slate" radius="medium" scaling="100%">
<DateFormatProvider>
<Component {...pageProps} />
</DateFormatProvider>
</Theme>
</ThemeProvider>
<ErrorBoundary>
<ThemeProvider
attribute="class"
defaultTheme="system"
// Cloudflare Rocket Loader breaks the script injection and causes theme flashing
scriptProps={{ "data-cfasync": "false" }}
>
<Theme accentColor="indigo" grayColor="slate" radius="medium" scaling="100%">
<TelemetryProvider>
<DateFormatProvider>
<Component {...pageProps} />
</DateFormatProvider>
</TelemetryProvider>
</Theme>
</ThemeProvider>
</ErrorBoundary>
);
};

View File

@@ -12,6 +12,7 @@ import {
generateBootstrapWarning,
} from "@/rdap/services/type-detection";
import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/query";
import { useTelemetry } from "@/contexts/TelemetryContext";
export type WarningHandler = (warning: { message: string }) => void;
export type UrlUpdateHandler = (target: string, manuallySelectedType: TargetType | null) => void;
@@ -33,6 +34,8 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
// Used to allow repeatable lookups when weird errors happen.
const repeatableRef = useRef<string>("");
const { track } = useTelemetry();
const getTypeEasy = useCallback(async (target: string): Promise<Result<TargetType, Error>> => {
return detectTargetType(target, getRegistry);
}, []);
@@ -112,6 +115,9 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
targetType = detectedType.value;
}
// Track query start
const startTime = performance.now();
// Execute the RDAP query using the extracted service
const result = await executeRdapQuery(target, targetType, {
requestJSContact,
@@ -119,6 +125,56 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
repeatableUrl: repeatableRef.current,
});
// Calculate duration
const duration = performance.now() - startTime;
// Track query result
if (result.isOk) {
track({
name: "rdap_query",
properties: {
targetType,
success: true,
duration,
},
});
} else {
// Determine error type
let errorType = "unknown_error";
if (result.error instanceof HttpSecurityError) {
errorType = "http_security_error";
} else if (result.error.message.includes("network")) {
errorType = "network_error";
} else if (result.error.message.includes("validation")) {
errorType = "validation_error";
}
track({
name: "rdap_query",
properties: {
targetType,
target,
success: false,
errorType,
duration,
},
});
// Also track detailed error
track({
name: "error",
properties: {
errorType: "rdap_query_error",
message: result.error.message,
stack: result.error.stack,
context: {
target,
targetType,
},
},
});
}
// Update repeatable ref if we got an HTTP security error for domain lookups
if (result.isErr && result.error instanceof HttpSecurityError) {
repeatableRef.current = result.error.url;

114
src/telemetry/client.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Telemetry client wrapper for PostHog with type-safe event tracking.
* Provides console logging in development/CI environments when PostHog keys are not configured.
*/
import posthog from "posthog-js";
import { env } from "@/env/client.mjs";
import type { TelemetryEvent } from "@/telemetry/events";
class TelemetryClient {
private initialized = false;
private enabled = false;
/**
* Centralized logging method that only logs in development or when PostHog is disabled
*/
private log(message: string, data?: unknown): void {
if (process.env.NODE_ENV !== "production" || !this.enabled) {
if (data !== undefined) {
console.log(`[Telemetry] ${message}`, data);
} else {
console.log(`[Telemetry] ${message}`);
}
}
}
/**
* Ensure the client is initialized before use
*/
private ensureInitialized(): void {
if (!this.initialized) {
this.init();
}
}
/**
* Initialize the PostHog client if keys are available
*/
init(): void {
if (this.initialized) return;
// Only enable PostHog if both key and host are configured
if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST) {
posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: env.NEXT_PUBLIC_POSTHOG_HOST,
loaded: (ph) => {
// Disable in development for debugging purposes
if (process.env.NODE_ENV === "development") {
ph.debug();
}
},
capture_pageview: false, // We'll handle page views manually
capture_pageleave: true,
persistence: "localStorage",
});
this.enabled = true;
this.log("PostHog initialized");
} else {
this.enabled = false;
this.log("PostHog not configured, console logging enabled");
}
this.initialized = true;
}
/**
* Track a telemetry event with type safety
*/
track<E extends TelemetryEvent>(event: E): void {
this.ensureInitialized();
this.log(event.name, event.properties);
if (this.enabled) {
posthog.capture(event.name, event.properties);
}
}
/**
* Identify a user with properties
*/
identify(userId: string, properties?: Record<string, unknown>): void {
this.ensureInitialized();
this.log("identify", { userId, properties });
if (this.enabled) {
posthog.identify(userId, properties);
}
}
/**
* Reset user identification (e.g., on logout)
*/
reset(): void {
if (!this.initialized) return;
this.log("reset");
if (this.enabled) {
posthog.reset();
}
}
/**
* Check if telemetry is enabled
*/
isEnabled(): boolean {
this.ensureInitialized();
return this.enabled;
}
}
/**
* Singleton telemetry client instance
*/
export const telemetry = new TelemetryClient();

91
src/telemetry/events.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Type-safe telemetry event system using discriminated unions.
* All events must have a 'name' discriminator property.
*/
import type { TargetType } from "@/rdap/schemas";
/**
* Page view tracking event
*/
export type PageViewEvent = {
name: "page_view";
properties: {
route: string;
referrer?: string;
duration?: number;
};
};
/**
* RDAP query tracking event
*/
export type RdapQueryEvent = {
name: "rdap_query";
properties: {
targetType: TargetType;
target?: string;
success: boolean;
errorType?: string;
duration?: number;
};
};
/**
* User interaction tracking event
*/
export type UserInteractionEvent = {
name: "user_interaction";
properties: {
action:
| "theme_toggle"
| "date_format_change"
| "copy_button"
| "link_click"
| "expand_section"
| "collapse_section"
| string;
component?: string;
value?: string | number | boolean;
};
};
/**
* Error tracking event
*/
export type ErrorEvent = {
name: "error";
properties: {
errorType:
| "rdap_query_error"
| "network_error"
| "validation_error"
| "runtime_error"
| string;
message: string;
stack?: string;
context?: Record<string, unknown>;
};
};
/**
* Discriminated union of all possible events
*/
export type TelemetryEvent = PageViewEvent | RdapQueryEvent | UserInteractionEvent | ErrorEvent;
/**
* Helper type to extract event properties by event name
*/
export type EventProperties<T extends TelemetryEvent["name"]> = Extract<
TelemetryEvent,
{ name: T }
>["properties"];
/**
* Type guard to ensure an event conforms to the TelemetryEvent schema
*/
export function isValidEvent(event: unknown): event is TelemetryEvent {
if (!event || typeof event !== "object") return false;
const e = event as Partial<TelemetryEvent>;
return typeof e.name === "string" && typeof e.properties === "object" && e.properties !== null;
}

14
src/telemetry/index.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Telemetry module exports
*/
export { telemetry } from "@/telemetry/client";
export { useTelemetry } from "@/contexts/TelemetryContext";
export type {
TelemetryEvent,
PageViewEvent,
RdapQueryEvent,
UserInteractionEvent,
ErrorEvent,
EventProperties,
} from "@/telemetry/events";