Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- `lib/inngest/` Inngest client and functions for event-driven background section revalidation.
- `lib/db/` Drizzle ORM schema, migrations, and repository layer for Postgres persistence.
- `lib/db/repos/` repository layer for each table (domains, certificates, dns, favicons, headers, hosting, providers, registrations, screenshots, seo).
- `lib/logger/` unified structured logging system with OpenTelemetry integration, correlation IDs, and PII-safe field filtering.
- `server/` backend integrations and tRPC routers; isolate DNS, RDAP/WHOIS, TLS, and header probing services.
- `server/routers/` tRPC router definitions (`_app.ts` and domain-specific routers).
- `server/services/` service layer for domain data fetching (DNS, certificates, headers, hosting, registration, SEO, screenshot, favicon, etc.).
Expand Down Expand Up @@ -49,6 +50,7 @@
- Uses `threads` pool for compatibility with sandboxed environments (e.g., Cursor agent commands).
- Global setup in `vitest.setup.ts`:
- Mocks analytics clients/servers (`@/lib/analytics/server` and `@/lib/analytics/client`).
- Mocks logger clients/servers (`@/lib/logger/server` and `@/lib/logger/client`).
- Mocks `server-only` module.
- Database in tests: Drizzle client is not globally mocked. Replace `@/lib/db/client` with a PGlite-backed instance when needed (`@/lib/db/pglite`).
- UI tests:
Expand Down Expand Up @@ -91,3 +93,39 @@
- Leverages Next.js 16 `after()` for background event capture with graceful degradation.
- Distinct ID sourced from PostHog cookie via `cache()`-wrapped `getDistinctId()` to comply with Next.js restrictions.
- Analytics mocked in tests via `vitest.setup.ts`.

## Structured Logging
- Unified logging system in `lib/logger/` with server (`lib/logger/server.ts`) and client (`lib/logger/client.ts`) implementations.
- **Server-side logging:**
- Import singleton: `import { logger } from "@/lib/logger/server"`
- Or create service logger: `const logger = createLogger({ source: "dns" })`
- Automatic OpenTelemetry trace/span ID injection from `@vercel/otel`
- Correlation ID tracking via AsyncLocalStorage for request tracing
- Critical errors automatically tracked in PostHog via `after()`
- Log levels: `trace`, `debug`, `info`, `warn`, `error`, `fatal`
- **Client-side logging:**
- Import singleton: `import { logger } from "@/lib/logger/client"`
- Or use hook: `const logger = useLogger({ component: "MyComponent" })`
- Errors automatically tracked in PostHog
- Console output only in development (info/debug) and always for errors
- Correlation IDs propagated from server via header/cookie/localStorage
- **Log format:** Structured JSON with consistent fields (level, message, timestamp, context, correlationId, traceId, spanId, environment).
- **Usage examples:**
```typescript
// Server (service layer)
import { createLogger } from "@/lib/logger/server";
const logger = createLogger({ source: "dns" });
logger.debug("start example.com", { domain: "example.com" });
logger.info("ok example.com", { domain: "example.com", count: 5 });
logger.error("failed to resolve", error, { domain: "example.com" });

// Client (components)
import { useLogger } from "@/hooks/use-logger";
const logger = useLogger({ component: "DomainSearch" });
logger.info("search initiated", { domain: query });
logger.error("search failed", error, { domain: query });
```
- **Correlation IDs:** Generated server-side, propagated to client via `x-correlation-id` header, stored in cookie/localStorage. Enables request tracing across services.
- **Integration with tRPC:** Middleware in `trpc/init.ts` automatically logs all procedures with correlation IDs and OpenTelemetry context.
- **Testing:** Logger mocked in `vitest.setup.ts`. Use `vi.mocked(logger.info)` to assert log calls in tests.

28 changes: 23 additions & 5 deletions app/api/trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { CORRELATION_ID_HEADER } from "@/lib/logger/correlation";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/trpc/init";

const handler = (req: Request) =>
fetchRequestHandler({
const handler = async (req: Request) => {
// Extract correlation ID from context to add to response headers
const ctx = await createContext({ req });

return fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext({ req }),
onError: ({ path, error }) => {
console.error(`[trpc] unhandled error ${path}`, error);
createContext: () => ctx,
onError: async ({ path, error }) => {
// Use logger for unhandled errors
const { logger } = await import("@/lib/logger/server");
logger.error(`[trpc] unhandled error ${path}`, error, { path });
},
responseMeta: () => {
// Add correlation ID to response headers for client tracking
if (ctx.correlationId) {
return {
headers: {
[CORRELATION_ID_HEADER]: ctx.correlationId,
},
};
}
return {};
},
});
};

export { handler as GET, handler as POST };
6 changes: 4 additions & 2 deletions app/error.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"use client";

import { RefreshCcw } from "lucide-react";
import posthog from "posthog-js";
import { useEffect } from "react";
import { CreateIssueButton } from "@/components/create-issue-button";
import { Button } from "@/components/ui/button";
import { logger } from "@/lib/logger/client";

export default function RootError(props: {
error: Error & { digest?: string };
Expand All @@ -13,7 +13,9 @@ export default function RootError(props: {
const { error, reset } = props;

useEffect(() => {
posthog.captureException(error);
logger.error("Root error boundary caught error", error, {
digest: error.digest,
});
}, [error]);

const isDev = process.env.NODE_ENV !== "production";
Expand Down
4 changes: 2 additions & 2 deletions app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import NextError from "next/error";
import posthog from "posthog-js";
import { useEffect } from "react";
import { logger } from "@/lib/logger/client";

export default function GlobalError({
error,
Expand All @@ -12,7 +12,7 @@ export default function GlobalError({
reset: () => void;
}) {
useEffect(() => {
posthog.captureException(error);
logger.error("Global error boundary caught error", error);
}, [error]);

return (
Expand Down
5 changes: 1 addition & 4 deletions components/domain/create-section-with-data.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
/**
* @vitest-environment jsdom
*/

/* @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
Expand Down
15 changes: 7 additions & 8 deletions components/domain/section-error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ReactNode } from "react";
import { Component } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { createLogger } from "@/lib/logger/client";

interface Props {
children: ReactNode;
Expand All @@ -22,6 +23,8 @@ interface State {
* Catches rendering errors and provides a fallback UI without crashing the entire page.
*/
export class SectionErrorBoundary extends Component<Props, State> {
private logger = createLogger({ component: "SectionErrorBoundary" });

constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
Expand All @@ -38,14 +41,10 @@ export class SectionErrorBoundary extends Component<Props, State> {
componentStack: errorInfo.componentStack,
});

// Also log to console in development
if (process.env.NODE_ENV === "development") {
console.error(
`[SectionErrorBoundary] Error in ${this.props.sectionName}:`,
error,
errorInfo,
);
}
this.logger.error("render error", error, {
section: this.props.sectionName,
componentStack: errorInfo.componentStack,
});
}

render() {
Expand Down
19 changes: 7 additions & 12 deletions hooks/use-domain-export.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { notifyManager, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useLogger } from "@/hooks/use-logger";
import { analytics } from "@/lib/analytics/client";
import { exportDomainData } from "@/lib/json-export";

Expand All @@ -19,6 +20,7 @@ type QueryKeys = {
*/
export function useDomainExport(domain: string, queryKeys: QueryKeys) {
const queryClient = useQueryClient();
const logger = useLogger({ component: "DomainExport" });
const [allDataLoaded, setAllDataLoaded] = useState(false);
const queryKeysRef = useRef(queryKeys);

Expand Down Expand Up @@ -81,26 +83,19 @@ export function useDomainExport(domain: string, queryKeys: QueryKeys) {

// Export with partial data (graceful degradation)
exportDomainData(domain, exportData);
} catch (error) {
console.error("[export] failed to export domain data", error);

analytics.trackException(
error instanceof Error ? error : new Error(String(error)),
{
domain,
},
);
} catch (err) {
logger.error("failed to export domain data", err, { domain });

// Show error toast
toast.error(`Failed to export ${domain}`, {
description:
error instanceof Error
? error.message
err instanceof Error
? err.message
: "An error occurred while exporting",
position: "bottom-center",
});
}
}, [domain, queryClient, queryKeys]);
}, [domain, queryClient, queryKeys, logger]);

return { handleExport, allDataLoaded };
}
50 changes: 50 additions & 0 deletions hooks/use-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import { useMemo } from "react";
import { logger as clientLogger, createLogger } from "@/lib/logger/client";
import type { LogContext, Logger } from "@/lib/logger/index";

/**
* React hook for component-level logging.
*
* Creates a memoized logger instance with component-specific context.
* The logger automatically includes the correlation ID and any provided context.
*
* @param baseContext - Optional context to be included with all logs from this logger
* @returns Logger instance
*
* @example
* ```tsx
* function DomainSearch() {
* const logger = useLogger({ component: "DomainSearch" });
*
* const handleSearch = (query: string) => {
* logger.info("search_initiated", { query });
* // ... search logic
* };
*
* return <input onChange={(e) => handleSearch(e.target.value)} />;
* }
* ```
*/
export function useLogger(baseContext?: LogContext): Logger {
// Generate a stable key for the context to prevent logger recreation on every render
// when using inline object literals (e.g. useLogger({ component: "..." })).
// We use JSON.stringify as it handles the most common case of simple value objects.
let contextKey: string | LogContext | undefined;
try {
contextKey = baseContext ? JSON.stringify(baseContext) : undefined;
} catch {
// Fallback to object reference if serialization fails (e.g. circular refs)
contextKey = baseContext;
}

// biome-ignore lint/correctness/useExhaustiveDependencies: We use contextKey to control memoization, but we need baseContext for creation. Since equal keys imply equal content (for serializable objects), using the captured baseContext from the first render that produced this key is safe.
return useMemo(() => {
if (baseContext) {
return createLogger(baseContext);
}
// Return singleton logger if no context provided
return clientLogger;
}, [contextKey]);
}
12 changes: 5 additions & 7 deletions instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,18 @@ export const onRequestError: Instrumentation.onRequestError = async (
// Only track errors in Node.js runtime (not Edge)
if (process.env.NEXT_RUNTIME === "nodejs") {
try {
// Dynamic imports for Node.js-only code
const { analytics } = await import("@/lib/analytics/server");

// Note: we let analytics.trackException handle distinctId extraction from cookies
analytics.trackException(
// Use logger for structured error logging
const { logger } = await import("@/lib/logger/server");
logger.error(
"[instrumentation] request error",
error instanceof Error ? error : new Error(String(error)),
{
path: request.path,
method: request.method,
},
);
} catch (trackingError) {
} catch {
// Graceful degradation - don't throw to avoid breaking the request
console.error("[instrumentation] error tracking failed:", trackingError);
}
}
};
3 changes: 3 additions & 0 deletions lib/analytics/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ function track(event: string, properties?: Record<string, unknown>) {
}
}

/**
* @internal Use logger.error() instead, which automatically tracks exceptions.
*/
function trackException(error: Error, properties?: Record<string, unknown>) {
try {
posthog.captureException(error, properties);
Expand Down
3 changes: 3 additions & 0 deletions lib/analytics/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export const analytics = {
}
},

/**
* @internal Use logger.error() instead, which automatically tracks exceptions.
*/
trackException: (
error: Error,
properties: Record<string, unknown>,
Expand Down
10 changes: 8 additions & 2 deletions lib/blob.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import "server-only";

import { del, put } from "@vercel/blob";
import { createLogger } from "@/lib/logger/server";

const logger = createLogger({ source: "blob" });

/**
* Upload a buffer to Vercel Blob storage
Expand Down Expand Up @@ -44,9 +47,12 @@ export async function deleteBlobs(urls: string[]): Promise<DeleteResult> {
results.push({ url, deleted: true });
} catch (err) {
const message = (err as Error)?.message || "unknown";
console.error(
`[blob] delete failed ${url}`,
logger.error(
"delete failed",
err instanceof Error ? err : new Error(String(err)),
{
url,
},
);
results.push({ url, deleted: false, error: message });
}
Expand Down
10 changes: 5 additions & 5 deletions lib/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import * as ipaddr from "ipaddr.js";
import { cacheLife, cacheTag } from "next/cache";
import { CLOUDFLARE_IPS_URL } from "@/lib/constants/external-apis";
import { ipV4InCidr, ipV6InCidr } from "@/lib/ip";
import { createLogger } from "@/lib/logger/server";

const logger = createLogger({ source: "cloudflare-ips" });

export interface CloudflareIpRanges {
ipv4Cidrs: string[];
Expand Down Expand Up @@ -87,13 +90,10 @@ async function getCloudflareIpRanges(): Promise<CloudflareIpRanges> {
try {
const ranges = await fetchCloudflareIpRanges();
parseAndCacheRanges(ranges);
console.info("[cloudflare-ips] IP ranges fetched");
logger.info("IP ranges fetched");
return ranges;
} catch (err) {
console.error(
"[cloudflare-ips] fetch error",
err instanceof Error ? err : new Error(String(err)),
);
logger.error("fetch error", err);
// Return empty ranges on error
return { ipv4Cidrs: [], ipv6Cidrs: [] };
}
Expand Down
Loading