Skip to content
4 changes: 4 additions & 0 deletions app/[domain]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { DomainReportView } from "@/components/domain/domain-report-view";
import { analytics } from "@/lib/analytics/server";
import { normalizeDomainInput } from "@/lib/domain";
import { toRegistrableDomain } from "@/lib/domain-server";
import { makeQueryClient } from "@/trpc/query-client";
Expand Down Expand Up @@ -47,6 +48,9 @@ export default async function DomainPage({
redirect(`/${encodeURIComponent(normalized)}`);
}

// Track server-side page view
analytics.track("report_viewed", { domain: normalized });

// Minimal prefetch: registration only, let sections stream progressively
const queryClient = makeQueryClient();
queryClient.prefetchQuery(
Expand Down
8 changes: 3 additions & 5 deletions components/copy-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Check, ClipboardCheck, Copy } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { captureClient } from "@/lib/analytics/client";

interface CopyButtonProps {
value: string;
Expand All @@ -17,15 +16,14 @@ export function CopyButton({ value, label }: CopyButtonProps) {

const handleCopy = () => {
navigator.clipboard.writeText(value);

toast.success("Copied!", {
icon: <ClipboardCheck className="h-4 w-4" />,
position: "bottom-center",
});
captureClient("copy_clicked", {
label: label ?? null,
value_length: value.length,
});

setCopied(true);

if (resetTimerRef.current) {
window.clearTimeout(resetTimerRef.current);
}
Expand Down
6 changes: 4 additions & 2 deletions components/domain/domain-report-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ExportButton } from "@/components/domain/export-button";
import { Favicon } from "@/components/domain/favicon";
import { ScreenshotTooltip } from "@/components/domain/screenshot-tooltip";
import { ToolsDropdown } from "@/components/domain/tools-dropdown";
import { captureClient } from "@/lib/analytics/client";
import { useAnalytics } from "@/lib/analytics/client";

interface DomainReportHeaderProps {
domain: string;
Expand All @@ -22,6 +22,8 @@ export function DomainReportHeader({
onExport,
exportDisabled,
}: DomainReportHeaderProps) {
const analytics = useAnalytics();

return (
<div className="flex items-center justify-between">
<ScreenshotTooltip domain={domain}>
Expand All @@ -31,7 +33,7 @@ export function DomainReportHeader({
rel="noopener"
className="flex items-center gap-2"
onClick={() =>
captureClient("external_domain_link_clicked", { domain })
analytics.track("external_domain_link_clicked", { domain })
}
>
<Favicon domain={domain} size={20} className="rounded" />
Expand Down
11 changes: 0 additions & 11 deletions components/domain/domain-report-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@ import { userEvent } from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DomainReportView } from "./domain-report-view";

// Mock all required modules
vi.mock("@/lib/analytics/client", () => ({
captureClient: vi.fn(),
}));

vi.mock("@/lib/json-export", () => ({
exportDomainData: vi.fn(),
}));
Expand Down Expand Up @@ -181,7 +176,6 @@ describe("DomainReportView Export", () => {
});
it("calls exportDomainData with cached query data when Export button is clicked", async () => {
const { exportDomainData } = await import("@/lib/json-export");
const { captureClient } = await import("@/lib/analytics/client");

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -227,11 +221,6 @@ describe("DomainReportView Export", () => {
// Click export button
await userEvent.click(exportButton);

// Verify analytics was captured
expect(captureClient).toHaveBeenCalledWith("export_json_clicked", {
domain,
});

// Verify exportDomainData was called with aggregated data
expect(exportDomainData).toHaveBeenCalledWith(domain, {
registration: { isRegistered: true, domain: "example.com" },
Expand Down
12 changes: 1 addition & 11 deletions components/domain/domain-report-view.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { Suspense, useEffect } from "react";
import { Suspense } from "react";
import { CertificatesSection } from "@/components/domain/certificates/certificates-section";
import { CertificatesSectionSkeleton } from "@/components/domain/certificates/certificates-section-skeleton";
import { createSectionWithData } from "@/components/domain/create-section-with-data";
Expand Down Expand Up @@ -28,7 +28,6 @@ import {
useSeoQuery,
} from "@/hooks/use-domain-queries";
import { useDomainQueryKeys } from "@/hooks/use-domain-query-keys";
import { captureClient } from "@/lib/analytics/client";

// Create section components using the factory
const RegistrationSectionWithData = createSectionWithData(
Expand Down Expand Up @@ -87,15 +86,6 @@ function DomainReportContent({ domain }: { domain: string }) {
// Add to search history (only for registered domains)
useDomainHistory(isConfirmedUnregistered ? "" : domain);

// Capture analytics event when registration state resolves
useEffect(() => {
if (isConfirmedUnregistered) {
captureClient("unregistered_viewed", { domain });
} else {
captureClient("report_viewed", { domain });
}
}, [domain, isConfirmedUnregistered]);

// Get memoized query keys for all sections
const queryKeys = useDomainQueryKeys(domain);

Expand Down
9 changes: 4 additions & 5 deletions components/domain/domain-suggestions-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "@/components/ui/tooltip";
import { useDomainHistory } from "@/hooks/use-domain-history";
import { useRouter } from "@/hooks/use-router";
import { captureClient } from "@/lib/analytics/client";
import { useAnalytics } from "@/lib/analytics/client";
import { MAX_HISTORY_ITEMS } from "@/lib/constants";
import { cn } from "@/lib/utils";

Expand All @@ -30,6 +30,7 @@ export function DomainSuggestionsClient({
max = MAX_HISTORY_ITEMS,
}: DomainSuggestionsClientProps) {
const router = useRouter();
const analytics = useAnalytics();
const { onSuggestionClickAction } = useHomeSearch();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const updateGradientsRef = useRef<(() => void) | null>(null);
Expand Down Expand Up @@ -103,7 +104,7 @@ export function DomainSuggestionsClient({
}, []); // Only run once on mount

function handleClick(domain: string) {
captureClient("search_suggestion_clicked", {
analytics.track("search_suggestion_clicked", {
domain,
source: "suggestion",
});
Expand All @@ -116,9 +117,7 @@ export function DomainSuggestionsClient({

function handleClearHistory() {
clearHistory();
captureClient("search_history_cleared", {
source: "suggestion",
});
analytics.track("search_history_cleared");

// Scroll back to the left with smooth animation
if (scrollContainerRef.current) {
Expand Down
15 changes: 9 additions & 6 deletions hooks/use-domain-export.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { notifyManager, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { captureClient } from "@/lib/analytics/client";
import { analytics } from "@/lib/analytics/client";
import { exportDomainData } from "@/lib/json-export";

type QueryKeys = {
Expand Down Expand Up @@ -55,7 +55,7 @@ export function useDomainExport(domain: string, queryKeys: QueryKeys) {

// Export handler that reads all data from React Query cache
const handleExport = useCallback(() => {
captureClient("export_json_clicked", { domain });
analytics.track("export_json_clicked", { domain });

try {
// Read data from cache using provided query keys
Expand Down Expand Up @@ -83,10 +83,13 @@ export function useDomainExport(domain: string, queryKeys: QueryKeys) {
exportDomainData(domain, exportData);
} catch (error) {
console.error("[export] failed to export domain data", error);
captureClient("export_json_failed", {
domain,
error: error instanceof Error ? error.message : String(error),
});

analytics.trackException(
error instanceof Error ? error : new Error(String(error)),
{
domain,
},
);

// Show error toast
toast.error(`Failed to export ${domain}`, {
Expand Down
6 changes: 3 additions & 3 deletions hooks/use-domain-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createElement, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { useRouter } from "@/hooks/use-router";
import { captureClient } from "@/lib/analytics/client";
import { analytics } from "@/lib/analytics/client";
import { isValidDomain, normalizeDomainInput } from "@/lib/domain";

const domainSchema = z
Expand Down Expand Up @@ -79,7 +79,7 @@ export function useDomainSearch(options: UseDomainSearchOptions = {}) {

function navigateToDomain(domain: string, navigateSource: Source = source) {
const target = normalizeDomainInput(domain);
captureClient("search_submitted", {
analytics.track("search_submitted", {
domain: target,
source: navigateSource,
});
Expand All @@ -104,7 +104,7 @@ export function useDomainSearch(options: UseDomainSearchOptions = {}) {
const firstIssue = parsed.error.issues?.[0] as
| { code?: string; message?: string }
| undefined;
captureClient("search_invalid_input", {
analytics.track("search_invalid_input", {
reason: firstIssue?.code ?? "invalid",
value_length: value.length,
});
Expand Down
46 changes: 10 additions & 36 deletions instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { waitUntil } from "@vercel/functions";
import { registerOTel } from "@vercel/otel";
import type { Instrumentation } from "next";

Expand All @@ -7,48 +6,23 @@ export const register = () => {
};

export const onRequestError: Instrumentation.onRequestError = async (
err,
error,
request,
) => {
// Only track errors in Node.js runtime (not Edge)
if (process.env.NEXT_RUNTIME === "nodejs") {
try {
// Dynamic imports for Node.js-only code
const { getServerPosthog } = await import("@/lib/analytics/server");
const phClient = getServerPosthog();
const { analytics } = await import("@/lib/analytics/server");

if (!phClient) {
return; // PostHog not available, skip error tracking
}

let distinctId: string | null = null;
if (request.headers?.cookie) {
const cookieString = request.headers.cookie;
const postHogCookieMatch =
typeof cookieString === "string"
? cookieString.match(/ph_phc_.*?_posthog=([^;]+)/)
: null;

if (postHogCookieMatch?.[1]) {
try {
const decodedCookie = decodeURIComponent(postHogCookieMatch[1]);
const postHogData = JSON.parse(decodedCookie);
distinctId = postHogData.distinct_id;
} catch (err) {
console.error(
"[instrumentation] cookie parse error",
err instanceof Error ? err : new Error(String(err)),
);
}
}
}

phClient.captureException(err, distinctId || undefined, {
path: request.path,
method: request.method,
});

waitUntil?.(phClient.shutdown());
// Note: we let analytics.trackException handle distinctId extraction from cookies
analytics.trackException(
error instanceof Error ? error : new Error(String(error)),
{
path: request.path,
method: request.method,
},
);
} catch (trackingError) {
// Graceful degradation - don't throw to avoid breaking the request
console.error("[instrumentation] error tracking failed:", trackingError);
Expand Down
44 changes: 40 additions & 4 deletions lib/analytics/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,50 @@
"use client";

import posthog from "posthog-js";
import { useMemo } from "react";

export const captureClient = (
event: string,
properties?: Record<string, unknown>,
) => {
function track(event: string, properties?: Record<string, unknown>) {
try {
posthog.capture(event, properties);
} catch {
// no-op
}
}

function trackException(error: Error, properties?: Record<string, unknown>) {
try {
posthog.captureException(error, properties);
} catch {
// no-op
}
}

/**
* Analytics tracking utility for non-React contexts.
* Use this in hooks or other non-component code.
*/
export const analytics = {
track,
trackException,
};

/**
* Analytics tracking hook for React components.
* Use this in components for tracking user interactions.
*
* @example
* ```tsx
* const analytics = useAnalytics();
* analytics.track("button_clicked", { button: "export" });
* analytics.trackException(error, { context: "export" });
* ```
*/
export function useAnalytics() {
return useMemo(
() => ({
track,
trackException,
}),
[],
);
}
Loading