- 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>
230 lines
6.1 KiB
TypeScript
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;
|
|
}
|
|
}
|