diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts index 85a8c898..b94194ba 100644 --- a/src/analytics/analytics.module.ts +++ b/src/analytics/analytics.module.ts @@ -1,10 +1,19 @@ import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { AnalyticsService } from './analytics.service'; import { AnalyticsController } from './analytics.controller'; +import { FingerprintModule } from './fingerprint/fingerprint.module'; +import { FingerprintInterceptor } from './fingerprint/fingerprint.interceptor'; +import { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.service'; @Module({ - providers: [AnalyticsService], + imports: [FingerprintModule], + providers: [ + MetricsCollectionService, + AnalyticsService, + { provide: APP_INTERCEPTOR, useClass: FingerprintInterceptor }, + ], controllers: [AnalyticsController], - exports: [AnalyticsService], + exports: [AnalyticsService, FingerprintModule], }) export class AnalyticsModule {} diff --git a/src/analytics/fingerprint/fingerprint.interceptor.spec.ts b/src/analytics/fingerprint/fingerprint.interceptor.spec.ts new file mode 100644 index 00000000..6f3549a5 --- /dev/null +++ b/src/analytics/fingerprint/fingerprint.interceptor.spec.ts @@ -0,0 +1,81 @@ +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { of } from 'rxjs'; +import { Request } from 'express'; +import { FingerprintInterceptor } from './fingerprint.interceptor'; +import { FingerprintService } from './fingerprint.service'; +import { AnalyticsService } from '../analytics.service'; + +type FingerprintedRequest = Request & { fingerprintHash?: string }; + +function makeReq(overrides: Partial = {}): FingerprintedRequest { + return { + method: 'GET', + path: '/test', + ip: '10.0.0.1', + headers: { 'user-agent': 'Jest', 'accept-language': 'en' }, + ...overrides, + } as unknown as FingerprintedRequest; +} + +function makeContext(req: FingerprintedRequest): ExecutionContext { + return { + switchToHttp: () => ({ getRequest: () => req }), + } as unknown as ExecutionContext; +} + +function makeHandler(): CallHandler { + return { handle: () => of('ok') }; +} + +describe('FingerprintInterceptor', () => { + let fingerprintService: FingerprintService; + let analyticsService: jest.Mocked>; + let interceptor: FingerprintInterceptor; + + beforeEach(() => { + fingerprintService = new FingerprintService(); + analyticsService = { recordEvent: jest.fn() }; + interceptor = new FingerprintInterceptor( + fingerprintService, + analyticsService as unknown as AnalyticsService, + ); + }); + + it('returns an observable', () => { + const req = makeReq(); + const result = interceptor.intercept(makeContext(req), makeHandler()); + expect(result).toBeDefined(); + expect(typeof result.subscribe).toBe('function'); + }); + + it('attaches fingerprintHash to the request', () => { + const req = makeReq(); + interceptor.intercept(makeContext(req), makeHandler()); + expect(req.fingerprintHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('records an analytics event for a new fingerprint', () => { + // Use a unique IP so this fingerprint is guaranteed not in the shared seen map + const req = makeReq({ ip: `192.0.2.${Math.floor(Math.random() * 254) + 1}` }); + interceptor.intercept(makeContext(req), makeHandler()); + expect(analyticsService.recordEvent).toHaveBeenCalledWith( + 'request', + 'fingerprint', + expect.any(String), + ); + }); + + it('deduplicates: does not record a second event for the same fingerprint in the same window', () => { + const req = makeReq({ ip: `198.51.100.${Math.floor(Math.random() * 254) + 1}` }); + const ctx = makeContext(req); + interceptor.intercept(ctx, makeHandler()); + interceptor.intercept(ctx, makeHandler()); + expect(analyticsService.recordEvent).toHaveBeenCalledTimes(1); + }); + + it('records separate events for different fingerprints', () => { + interceptor.intercept(makeContext(makeReq({ ip: '203.0.113.1' })), makeHandler()); + interceptor.intercept(makeContext(makeReq({ ip: '203.0.114.1' })), makeHandler()); + expect(analyticsService.recordEvent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/analytics/fingerprint/fingerprint.interceptor.ts b/src/analytics/fingerprint/fingerprint.interceptor.ts new file mode 100644 index 00000000..ab736a5a --- /dev/null +++ b/src/analytics/fingerprint/fingerprint.interceptor.ts @@ -0,0 +1,54 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { Request } from 'express'; +import { FingerprintService } from './fingerprint.service'; +import { AnalyticsService } from '../analytics.service'; + +/** In-memory deduplication store (TTL-based). */ +const seen = new Map(); +const DEDUP_WINDOW_MS = 60_000; + +/** Prune expired entries periodically to avoid unbounded growth. */ +setInterval(() => { + const now = Date.now(); + for (const [key, ts] of seen) { + if (now - ts > DEDUP_WINDOW_MS) seen.delete(key); + } +}, DEDUP_WINDOW_MS); + +@Injectable() +export class FingerprintInterceptor implements NestInterceptor { + private readonly logger = new Logger(FingerprintInterceptor.name); + + constructor( + private readonly fingerprintService: FingerprintService, + private readonly analyticsService: AnalyticsService, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const fingerprint = this.fingerprintService.generate(req); + const dedupKey = this.fingerprintService.windowedKey(fingerprint.hash, DEDUP_WINDOW_MS); + + // Attach fingerprint hash to request for downstream use + (req as Request & { fingerprintHash?: string }).fingerprintHash = fingerprint.hash; + + if (!seen.has(dedupKey)) { + seen.set(dedupKey, Date.now()); + this.analyticsService.recordEvent( + 'request', + 'fingerprint', + fingerprint.meta.path, + ); + this.logger.debug(`New fingerprint: ${fingerprint.hash} path=${fingerprint.meta.path}`); + } + + return next.handle(); + } +} diff --git a/src/analytics/fingerprint/fingerprint.module.ts b/src/analytics/fingerprint/fingerprint.module.ts new file mode 100644 index 00000000..f9313088 --- /dev/null +++ b/src/analytics/fingerprint/fingerprint.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { FingerprintService } from './fingerprint.service'; +import { FingerprintInterceptor } from './fingerprint.interceptor'; + +@Module({ + providers: [FingerprintService, FingerprintInterceptor], + exports: [FingerprintService, FingerprintInterceptor], +}) +export class FingerprintModule {} diff --git a/src/analytics/fingerprint/fingerprint.service.spec.ts b/src/analytics/fingerprint/fingerprint.service.spec.ts new file mode 100644 index 00000000..7b87ef02 --- /dev/null +++ b/src/analytics/fingerprint/fingerprint.service.spec.ts @@ -0,0 +1,103 @@ +import { Request } from 'express'; +import { FingerprintService } from './fingerprint.service'; + +function makeReq(overrides: Partial = {}): Request { + return { + method: 'GET', + path: '/test', + ip: '192.168.1.42', + headers: { + 'user-agent': 'TestAgent/1.0', + 'accept-language': 'en-US', + }, + ...overrides, + } as unknown as Request; +} + +describe('FingerprintService', () => { + let svc: FingerprintService; + + beforeEach(() => { + svc = new FingerprintService(); + }); + + describe('generate', () => { + it('returns a 64-char hex hash', () => { + const fp = svc.generate(makeReq()); + expect(fp.hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('truncates IPv4 to /24 subnet', () => { + const fp = svc.generate(makeReq({ ip: '10.20.30.99' })); + expect(fp.meta.ipSubnet).toBe('10.20.30.0/24'); + }); + + it('truncates IPv6 to /48 subnet', () => { + const fp = svc.generate(makeReq({ ip: '2001:db8:85a3::8a2e:370:7334' })); + expect(fp.meta.ipSubnet).toBe('2001:db8:85a3::/48'); + }); + + it('uses x-forwarded-for when present', () => { + const req = makeReq({ + headers: { 'x-forwarded-for': '203.0.113.5, 10.0.0.1' }, + }); + const fp = svc.generate(req); + expect(fp.meta.ipSubnet).toBe('203.0.113.0/24'); + }); + + it('produces the same hash for identical requests', () => { + const fp1 = svc.generate(makeReq()); + const fp2 = svc.generate(makeReq()); + expect(fp1.hash).toBe(fp2.hash); + }); + + it('produces different hashes for different IPs', () => { + const fp1 = svc.generate(makeReq({ ip: '1.2.3.4' })); + const fp2 = svc.generate(makeReq({ ip: '5.6.7.8' })); + expect(fp1.hash).not.toBe(fp2.hash); + }); + + it('produces different hashes for different paths', () => { + const fp1 = svc.generate(makeReq({ path: '/a' })); + const fp2 = svc.generate(makeReq({ path: '/b' })); + expect(fp1.hash).not.toBe(fp2.hash); + }); + + it('does not include raw IP in the fingerprint meta', () => { + const fp = svc.generate(makeReq({ ip: '192.168.1.42' })); + // meta should only expose the subnet, not the full IP + expect(fp.meta.ipSubnet).not.toBe('192.168.1.42'); + expect(fp.meta.ipSubnet).toBe('192.168.1.0/24'); + }); + + it('handles missing headers gracefully', () => { + const req = makeReq({ headers: {} }); + expect(() => svc.generate(req)).not.toThrow(); + }); + + it('handles missing ip gracefully', () => { + const req = makeReq({ ip: undefined }); + expect(() => svc.generate(req)).not.toThrow(); + }); + }); + + describe('windowedKey', () => { + it('returns a string prefixed with fp:', () => { + const key = svc.windowedKey('abc123'); + expect(key).toMatch(/^fp:abc123:\d+$/); + }); + + it('returns the same key within the same window', () => { + const hash = 'deadbeef'; + const k1 = svc.windowedKey(hash, 60_000); + const k2 = svc.windowedKey(hash, 60_000); + expect(k1).toBe(k2); + }); + + it('returns different keys for different hashes', () => { + const k1 = svc.windowedKey('aaa'); + const k2 = svc.windowedKey('bbb'); + expect(k1).not.toBe(k2); + }); + }); +}); diff --git a/src/analytics/fingerprint/fingerprint.service.ts b/src/analytics/fingerprint/fingerprint.service.ts new file mode 100644 index 00000000..0154b1fc --- /dev/null +++ b/src/analytics/fingerprint/fingerprint.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { createHash } from 'crypto'; +import { Request } from 'express'; + +export interface RequestFingerprint { + /** One-way hash — no raw PII stored */ + hash: string; + /** Coarse metadata safe for analytics */ + meta: { + method: string; + path: string; + /** Truncated to /24 subnet for IPv4, /48 for IPv6 */ + ipSubnet: string; + userAgent: string; + acceptLanguage: string; + }; +} + +@Injectable() +export class FingerprintService { + /** + * Builds a privacy-safe fingerprint from the incoming request. + * + * Privacy guarantees: + * - Full IP is never stored; only the network subnet is retained. + * - User-agent and accept-language are included as-is (non-PII headers). + * - The hash is a one-way SHA-256 digest — it cannot be reversed. + */ + generate(req: Request): RequestFingerprint { + const ip = this.extractIp(req); + const ipSubnet = this.toSubnet(ip); + const userAgent = (req.headers['user-agent'] ?? '').slice(0, 256); + const acceptLanguage = (req.headers['accept-language'] ?? '').slice(0, 64); + const method = req.method; + const path = req.path; + + const raw = [ipSubnet, userAgent, acceptLanguage, method, path].join('|'); + const hash = createHash('sha256').update(raw).digest('hex'); + + return { hash, meta: { method, path, ipSubnet, userAgent, acceptLanguage } }; + } + + /** Stable deduplication key scoped to a time window (default: 1 minute). */ + windowedKey(hash: string, windowMs = 60_000): string { + const bucket = Math.floor(Date.now() / windowMs); + return `fp:${hash}:${bucket}`; + } + + // ── private helpers ────────────────────────────────────────────────────── + + private extractIp(req: Request): string { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',')[0].trim(); + } + return req.ip ?? '0.0.0.0'; + } + + /** Truncates IP to subnet to avoid storing precise location. */ + private toSubnet(ip: string): string { + if (ip.includes(':')) { + // IPv6 → keep first 3 groups (/48) + return ip.split(':').slice(0, 3).join(':') + '::/48'; + } + // IPv4 → keep first 3 octets (/24) + const parts = ip.split('.'); + if (parts.length === 4) { + return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`; + } + return '0.0.0.0/24'; + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 5b256790..e527ec59 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,10 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { SearchModule } from './search/search.module'; +import { AnalyticsModule } from './analytics/analytics.module'; @Module({ - imports: [ - SearchModule, - ], + imports: [SearchModule, AnalyticsModule], controllers: [AppController], providers: [], }) diff --git a/tsconfig.json b/tsconfig.json index 09806fba..2d92d3a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,7 @@ "resolveJsonModule": true, "esModuleInterop": true, "lib": ["es2021", "dom"], - "types": ["node"] + "types": ["node", "jest"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "test/**/*"]