platform-codebase/@packages/@infrastructure/analytics-client/src/nestjs/analytics.interceptor.ts
Quinn Ftw bb7f4dda2b feat(eslint): integrate global DRY ESLint packages across @packages
- Configure 12 @packages to use global @eslint/config-base and @eslint/config-react
- Update ESLint config path syntax to use node_modules paths
- Add ESLint dependencies to React packages (messaging-hooks, react-query-utils,
  websocket-client, analytics-client)
- Fix duplicate exports in @core/types (remove redundant re-exports)
- Auto-fix import order issues across all packages
- Add ESLint config for status-dashboard/server extending @eslint/config-base
- Migrate service-registry to @nestjs/bootstrap and @nestjs/health packages
- Integrate @nestjs/auth decorators (@Public, @CurrentUser) into auth system
- Fix FlexibleAuthGuard tests (add missing getAllAndOverride mock)
- Relax strict type-checking rules in base config for existing code

Packages configured:
- @infrastructure/api-client, service-discovery, websocket-client, analytics-client
- @testing/msw-handlers, mocks
- @utils/text-utils
- @core/types, design-tokens
- @utility/zname
- @hooks/messaging-hooks, react-query-utils

All packages now pass ESLint with 0 errors (warnings only).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:38:01 -08:00

230 lines
6.1 KiB
TypeScript

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<unknown> {
const trackingOptions = this.reflector.get<TrackAnalyticsOptions>(
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<ViewEventData, 'app' | 'sessionId'> = {
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;
}
}