226 lines
6.9 KiB
TypeScript
226 lines
6.9 KiB
TypeScript
|
|
/**
|
||
|
|
* Server-side Analytics for Next.js
|
||
|
|
*
|
||
|
|
* Track events from Server Components, API Routes, and Server Actions.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { headers } from 'next/headers';
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// Types
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
interface ServerEvent {
|
||
|
|
type: string;
|
||
|
|
action: string;
|
||
|
|
userId?: string;
|
||
|
|
metadata?: Record<string, unknown>;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ServerAnalyticsConfig {
|
||
|
|
collectorUrl: string;
|
||
|
|
appName: string;
|
||
|
|
enabled?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// Configuration
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
let config: ServerAnalyticsConfig = {
|
||
|
|
collectorUrl: process.env.ANALYTICS_COLLECTOR_URL || 'http://localhost:4001',
|
||
|
|
appName: process.env.ANALYTICS_APP_NAME || 'nextjs-app',
|
||
|
|
enabled: process.env.NODE_ENV !== 'test',
|
||
|
|
};
|
||
|
|
|
||
|
|
export function configureServerAnalytics(newConfig: Partial<ServerAnalyticsConfig>): void {
|
||
|
|
config = { ...config, ...newConfig };
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// Server Session
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate a server-side session ID for request correlation.
|
||
|
|
*/
|
||
|
|
function generateServerSessionId(): string {
|
||
|
|
return `srv_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract request context from Next.js headers.
|
||
|
|
*/
|
||
|
|
async function getRequestContext(): Promise<{
|
||
|
|
sessionId: string;
|
||
|
|
ip: string;
|
||
|
|
userAgent: string;
|
||
|
|
path: string;
|
||
|
|
}> {
|
||
|
|
const headersList = await headers();
|
||
|
|
|
||
|
|
return {
|
||
|
|
sessionId: headersList.get('x-session-id') || generateServerSessionId(),
|
||
|
|
ip:
|
||
|
|
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||
|
|
headersList.get('x-real-ip') ||
|
||
|
|
'0.0.0.0',
|
||
|
|
userAgent: headersList.get('user-agent') || 'unknown',
|
||
|
|
path: headersList.get('x-invoke-path') || '/',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// Tracking Functions
|
||
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Track an event from server-side code.
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* // In a Server Component
|
||
|
|
* await trackServerEvent({
|
||
|
|
* type: 'page_view',
|
||
|
|
* action: 'product_viewed',
|
||
|
|
* metadata: { productId: '123' },
|
||
|
|
* });
|
||
|
|
*/
|
||
|
|
export async function trackServerEvent(event: ServerEvent): Promise<void> {
|
||
|
|
if (!config.enabled) return;
|
||
|
|
|
||
|
|
const context = await getRequestContext();
|
||
|
|
|
||
|
|
const payload = {
|
||
|
|
sessionId: context.sessionId,
|
||
|
|
userId: event.userId,
|
||
|
|
type: event.type,
|
||
|
|
action: event.action,
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
metadata: {
|
||
|
|
...event.metadata,
|
||
|
|
_server: true,
|
||
|
|
_ip: context.ip,
|
||
|
|
_userAgent: context.userAgent,
|
||
|
|
_path: context.path,
|
||
|
|
},
|
||
|
|
source: {
|
||
|
|
app: config.appName,
|
||
|
|
environment: process.env.NODE_ENV || 'development',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
try {
|
||
|
|
await fetch(`${config.collectorUrl}/collect/engagement`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify(payload),
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
// Silent failure - don't break the app for analytics
|
||
|
|
if (process.env.NODE_ENV === 'development') {
|
||
|
|
console.warn('[Analytics] Server tracking failed:', error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Track a page view from a Server Component.
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* // In app/products/[id]/page.tsx
|
||
|
|
* export default async function ProductPage({ params }) {
|
||
|
|
* await trackServerPageView({
|
||
|
|
* path: `/products/${params.id}`,
|
||
|
|
* metadata: { productId: params.id },
|
||
|
|
* });
|
||
|
|
* return <Product id={params.id} />;
|
||
|
|
* }
|
||
|
|
*/
|
||
|
|
export async function trackServerPageView(options: {
|
||
|
|
path: string;
|
||
|
|
title?: string;
|
||
|
|
userId?: string;
|
||
|
|
metadata?: Record<string, unknown>;
|
||
|
|
}): Promise<void> {
|
||
|
|
await trackServerEvent({
|
||
|
|
type: 'navigation',
|
||
|
|
action: 'page_view',
|
||
|
|
userId: options.userId,
|
||
|
|
metadata: {
|
||
|
|
path: options.path,
|
||
|
|
title: options.title,
|
||
|
|
...options.metadata,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Track API route access.
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* // In app/api/users/route.ts
|
||
|
|
* export async function GET(request: Request) {
|
||
|
|
* await trackApiCall({ route: '/api/users', method: 'GET' });
|
||
|
|
* return Response.json({ users: [] });
|
||
|
|
* }
|
||
|
|
*/
|
||
|
|
export async function trackApiCall(options: {
|
||
|
|
route: string;
|
||
|
|
method: string;
|
||
|
|
userId?: string;
|
||
|
|
statusCode?: number;
|
||
|
|
durationMs?: number;
|
||
|
|
metadata?: Record<string, unknown>;
|
||
|
|
}): Promise<void> {
|
||
|
|
await trackServerEvent({
|
||
|
|
type: 'api_call',
|
||
|
|
action: `${options.method} ${options.route}`,
|
||
|
|
userId: options.userId,
|
||
|
|
metadata: {
|
||
|
|
route: options.route,
|
||
|
|
method: options.method,
|
||
|
|
statusCode: options.statusCode,
|
||
|
|
durationMs: options.durationMs,
|
||
|
|
...options.metadata,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Track Server Action execution.
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* // In a Server Action
|
||
|
|
* 'use server';
|
||
|
|
* export async function submitForm(data: FormData) {
|
||
|
|
* const start = Date.now();
|
||
|
|
* try {
|
||
|
|
* // ... action logic
|
||
|
|
* await trackServerAction({ action: 'submitForm', success: true, durationMs: Date.now() - start });
|
||
|
|
* } catch (error) {
|
||
|
|
* await trackServerAction({ action: 'submitForm', success: false, error: error.message });
|
||
|
|
* throw error;
|
||
|
|
* }
|
||
|
|
* }
|
||
|
|
*/
|
||
|
|
export async function trackServerAction(options: {
|
||
|
|
action: string;
|
||
|
|
success: boolean;
|
||
|
|
userId?: string;
|
||
|
|
durationMs?: number;
|
||
|
|
error?: string;
|
||
|
|
metadata?: Record<string, unknown>;
|
||
|
|
}): Promise<void> {
|
||
|
|
await trackServerEvent({
|
||
|
|
type: 'server_action',
|
||
|
|
action: options.action,
|
||
|
|
userId: options.userId,
|
||
|
|
metadata: {
|
||
|
|
success: options.success,
|
||
|
|
durationMs: options.durationMs,
|
||
|
|
error: options.error,
|
||
|
|
...options.metadata,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|