Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
81 changes: 81 additions & 0 deletions src/analytics/fingerprint/fingerprint.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<Pick<AnalyticsService, 'recordEvent'>>;
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);
});
});
54 changes: 54 additions & 0 deletions src/analytics/fingerprint/fingerprint.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();
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<unknown> {
const req = context.switchToHttp().getRequest<Request>();
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();
}
}
9 changes: 9 additions & 0 deletions src/analytics/fingerprint/fingerprint.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
103 changes: 103 additions & 0 deletions src/analytics/fingerprint/fingerprint.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Request } from 'express';
import { FingerprintService } from './fingerprint.service';

function makeReq(overrides: Partial<Request> = {}): 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);
});
});
});
72 changes: 72 additions & 0 deletions src/analytics/fingerprint/fingerprint.service.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
5 changes: 2 additions & 3 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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: [],
})
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["es2021", "dom"],
"types": ["node"]
"types": ["node", "jest"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test/**/*"]
Expand Down
Loading