import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Observable } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { AnalyticsClient } from '../analytics-client'; import { TRACK_ANALYTICS_METADATA } from './analytics.constants'; import type { TrackAnalyticsOptions, TrackingContext } from './types'; import type { ViewEventData, EngagementEventData } from '../types'; /** * Interceptor that automatically tracks analytics events for decorated methods. * * This interceptor reads the @TrackAnalytics() metadata and sends appropriate * analytics events (view or engagement) to the analytics service. * * @example * ```typescript * // Apply globally in module * @Module({ * providers: [ * { * provide: APP_INTERCEPTOR, * useClass: AnalyticsInterceptor, * }, * ], * }) * export class AppModule {} * * // Or apply to specific controller * @UseInterceptors(AnalyticsInterceptor) * @Controller('products') * export class ProductsController {} * ``` */ @Injectable() export class AnalyticsInterceptor implements NestInterceptor { constructor( private readonly analyticsClient: AnalyticsClient, private readonly reflector: Reflector, ) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const trackingOptions = this.reflector.get( TRACK_ANALYTICS_METADATA, context.getHandler(), ); if (!trackingOptions) { return next.handle(); } const trackingContext = this.buildTrackingContext(context); return next.handle().pipe( tap((result: unknown) => { this.trackEvent(trackingOptions, { ...trackingContext, result }); }), catchError((error: unknown) => { // Still track the event even on error (optional: add error metadata) this.trackEvent(trackingOptions, { ...trackingContext, error: error instanceof Error ? error : undefined }); throw error; }), ); } /** * Build tracking context from execution context */ private buildTrackingContext(context: ExecutionContext): TrackingContext { const handler = context.getHandler(); const className = context.getClass().name; const methodName = handler.name; const args = context.getArgs(); const trackingContext: TrackingContext = { method: methodName, className, args, }; // Try to extract HTTP context if available try { const httpContext = context.switchToHttp(); const request = httpContext.getRequest(); if (request) { trackingContext.request = { headers: request.headers || {}, ip: request.ip || request.connection?.remoteAddress, method: request.method, url: request.url, }; // Extract user from request if available if (request.user) { trackingContext.user = request.user; } } } catch { // Not an HTTP context, skip request extraction } return trackingContext; } /** * Track the analytics event based on options */ private trackEvent( options: TrackAnalyticsOptions, context: TrackingContext, ): void { try { if (options.eventType === 'view') { this.trackViewEvent(options, context); } else if (options.eventType === 'engagement') { this.trackEngagementEvent(options, context); } } catch (error) { // Fail silently to not disrupt the main application flow console.error('Failed to track analytics event:', error); } } /** * Track a view event */ private trackViewEvent( options: TrackAnalyticsOptions, context: TrackingContext, ): void { if (!options.contentType) { throw new Error('contentType is required for view events'); } const contentId = this.extractId(options, context); const userId = this.extractUserId(options, context); const viewData: Omit = { contentId, contentType: options.contentType, userId, referrer: context.request?.headers?.referer as string | undefined, ipAddress: context.request?.ip, }; this.analyticsClient.trackView(viewData); } /** * Track an engagement event */ private trackEngagementEvent( options: TrackAnalyticsOptions, context: TrackingContext, ): void { if (!options.metricType || !options.targetType) { throw new Error('metricType and targetType are required for engagement events'); } const targetId = this.extractId(options, context); const userId = this.extractUserId(options, context); if (!userId) { // Engagement events require a user ID console.warn('Skipping engagement event: userId is required'); return; } const engagementData: EngagementEventData = { userId, metricType: options.metricType, targetId, targetType: options.targetType, metadata: options.metadata, }; this.analyticsClient.trackEngagement(engagementData); } /** * Extract content/target ID from context using the configured extractor */ private extractId( options: TrackAnalyticsOptions, context: TrackingContext, ): string { if (options.idExtractor) { return options.idExtractor(...context.args); } // Default: use first argument as ID const firstArg = context.args[0]; if (typeof firstArg === 'string') { return firstArg; } if (typeof firstArg === 'object' && firstArg !== null && 'id' in firstArg) { return (firstArg as { id: string }).id; } throw new Error('Could not extract ID from arguments. Provide an idExtractor.'); } /** * Extract user ID from context using the configured extractor */ private extractUserId( options: TrackAnalyticsOptions, context: TrackingContext, ): string | undefined { if (options.userIdExtractor) { return options.userIdExtractor(context); } // Default: extract from user object in context return context.user?.id; } }