mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-15 04:12:54 -06:00
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:
@@ -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
|
||||
|
||||
92
src/components/ErrorBoundary.tsx
Normal file
92
src/components/ErrorBoundary.tsx
Normal 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;
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
76
src/contexts/TelemetryContext.tsx
Normal file
76
src/contexts/TelemetryContext.tsx
Normal 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
7
src/env/schema.mjs
vendored
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
114
src/telemetry/client.ts
Normal 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
91
src/telemetry/events.ts
Normal 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
14
src/telemetry/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user