Skip to content
Merged
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
20 changes: 19 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module, DynamicModule, Type, Global } from '@nestjs/common';
import { Module, DynamicModule, Type, Logger, Global } from '@nestjs/common';
import { APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
Expand Down Expand Up @@ -88,6 +88,13 @@ export class AppModule {
10,
);

// Log pool configuration at startup for observability (#274)
const poolLogger = new Logger('DatabasePool');
poolLogger.log(
`DB pool config — max: ${poolMax}, min: ${poolMin}, ` +
`acquireTimeout: ${poolAcquireTimeoutMs}ms, idleTimeout: ${poolIdleTimeoutMs}ms`,
);

return {
type: 'postgres',
host: process.env.DATABASE_HOST || 'localhost',
Expand All @@ -106,6 +113,17 @@ export class AppModule {
min: poolMin,
connectionTimeoutMillis: poolAcquireTimeoutMs,
idleTimeoutMillis: poolIdleTimeoutMs,
// Pool event hooks for Prometheus metrics (#274).
// pg fires these on the underlying Pool instance after each
// acquire/release so we can track connection churn over time.
afterPoolConnect: (_client: unknown, _eventCount: number) => {
metricsService.dbPoolConnectionsAcquired.inc();
metricsService.dbPoolSize.inc();
},
afterPoolRelease: (_client: unknown, _eventCount: number) => {
metricsService.dbPoolConnectionsReleased.inc();
metricsService.dbPoolSize.dec();
},
},
};
},
Expand Down
32 changes: 32 additions & 0 deletions src/caching/caching.constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
export const CACHE_REDIS_CLIENT = 'CACHE_REDIS_CLIENT';

/**
* Cache TTL (Time-To-Live) values in seconds.
*
* ## Caching Strategy
*
* TeachLink uses a Redis-backed cache-aside pattern:
* 1. On read, check cache first; on miss, fetch from DB and populate cache.
* 2. On write/delete, invalidate or update the relevant cache keys.
*
* ### TTL Policy
* TTLs are chosen based on data volatility and read frequency:
*
* | Key | TTL | Rationale |
* |------------------|------------|------------------------------------------------|
* | USER_SESSION | 7 days | Long-lived auth sessions; invalidated on logout|
* | COURSE_METADATA | 15 min | Frequently read, infrequently updated |
* | COURSE_DETAILS | 5 min | May change (price, seats); short TTL |
* | SEARCH_RESULTS | 2 min | High read volume; stale results acceptable |
* | USER_PROFILE | 10 min | Moderate update frequency |
* | STATIC_CONTENT | 1 hour | Rarely changes; safe to cache long |
* | POPULAR_COURSES | 30 min | Computed ranking; refresh periodically |
* | ENROLLMENT_DATA | 5 min | Changes on enroll/unenroll events |
*
* ### Cache Key Versioning
* All keys are prefixed with `cache:<entity>` (see CACHE_PREFIXES).
* To bust all keys for an entity type, increment the version suffix:
* e.g. `cache:course:v2:<id>`
*
* ### Invalidation
* Cache invalidation is event-driven via CACHE_EVENTS. Services emit these
* events after mutations; the CachingModule subscribes and deletes stale keys.
*/
export const CACHE_TTL = {
USER_SESSION: 604800, // 7 days
COURSE_METADATA: 900, // 15 minutes
Expand Down
62 changes: 62 additions & 0 deletions src/common/exceptions/app.exceptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { HttpException, HttpStatus } from '@nestjs/common';

/**
* Thrown when a requested resource cannot be found.
* Maps to HTTP 404 Not Found.
*/
export class ResourceNotFoundException extends HttpException {
constructor(resource: string, id?: string | number) {
const message = id ? `${resource} with id '${id}' was not found` : `${resource} was not found`;
super({ message, error: 'Not Found', statusCode: HttpStatus.NOT_FOUND }, HttpStatus.NOT_FOUND);
}
}

/**
* Thrown when an operation is not permitted for the current user/state.
* Maps to HTTP 403 Forbidden.
*/
export class ForbiddenOperationException extends HttpException {
constructor(message = 'You do not have permission to perform this action') {
super({ message, error: 'Forbidden', statusCode: HttpStatus.FORBIDDEN }, HttpStatus.FORBIDDEN);
}
}

/**
* Thrown when a resource already exists (e.g. duplicate email).
* Maps to HTTP 409 Conflict.
*/
export class ResourceConflictException extends HttpException {
constructor(resource: string, field?: string) {
const message = field
? `${resource} with this ${field} already exists`
: `${resource} already exists`;
super({ message, error: 'Conflict', statusCode: HttpStatus.CONFLICT }, HttpStatus.CONFLICT);
}
}

/**
* Thrown when input data fails business-rule validation (beyond DTO constraints).
* Maps to HTTP 422 Unprocessable Entity.
*/
export class BusinessValidationException extends HttpException {
constructor(message: string) {
super(
{ message, error: 'Unprocessable Entity', statusCode: HttpStatus.UNPROCESSABLE_ENTITY },
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
}

/**
* Thrown when an external service or dependency is unavailable.
* Maps to HTTP 503 Service Unavailable.
*/
export class ServiceUnavailableException extends HttpException {
constructor(service: string) {
const message = `${service} is currently unavailable. Please try again later.`;
super(
{ message, error: 'Service Unavailable', statusCode: HttpStatus.SERVICE_UNAVAILABLE },
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
22 changes: 22 additions & 0 deletions src/monitoring/metrics/metrics-collection.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
public httpRequestDuration: Histogram;
public dbQueryDuration: Histogram;
public activeConnections: Gauge;
/** Tracks total DB pool connections acquired since startup (#274) */
public dbPoolConnectionsAcquired: Counter;
/** Tracks total DB pool connections released since startup (#274) */
public dbPoolConnectionsReleased: Counter;
/** Tracks current DB pool size (active + idle) (#274) */
public dbPoolSize: Gauge;
public userRegistrations: Counter;
public assessmentCompletions: Counter;
public learningPathProgress: Gauge;
Expand Down Expand Up @@ -43,46 +49,62 @@
registers: [this.registry],
});

// DB connection pool metrics (#274)
this.dbPoolConnectionsAcquired = new Counter({
name: 'db_pool_connections_acquired_total',
help: 'Total number of DB pool connections acquired',
registers: [this.registry],
});

this.dbPoolConnectionsReleased = new Counter({
name: 'db_pool_connections_released_total',
help: 'Total number of DB pool connections released',
registers: [this.registry],
});

this.dbPoolSize = new Gauge({
name: 'db_pool_size',
help: 'Current DB connection pool size (active + idle)',
// User Registrations Counter
this.userRegistrations = new Counter({

Check failure on line 69 in src/monitoring/metrics/metrics-collection.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

':' expected.
name: 'user_registrations_total',
help: 'Total number of user registrations',
labelNames: ['user_type', 'source'],
registers: [this.registry],
});

Check failure on line 74 in src/monitoring/metrics/metrics-collection.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

',' expected.

// Assessment Completions Counter
this.assessmentCompletions = new Counter({

Check failure on line 77 in src/monitoring/metrics/metrics-collection.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

':' expected.
name: 'assessment_completions_total',
help: 'Total number of assessment completions',
labelNames: ['assessment_type', 'difficulty'],
registers: [this.registry],
});

Check failure on line 82 in src/monitoring/metrics/metrics-collection.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

',' expected.

// Learning Path Progress Gauge
this.learningPathProgress = new Gauge({

Check failure on line 85 in src/monitoring/metrics/metrics-collection.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

':' expected.
name: 'learning_path_progress_percentage',
help: 'Average learning path progress percentage',
labelNames: ['path_id', 'user_id'],
registers: [this.registry],
});

Check failure on line 90 in src/monitoring/metrics/metrics-collection.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

',' expected.

// Cache Hit Rate Gauge
this.cacheHitRate = new Gauge({

Check failure on line 93 in src/monitoring/metrics/metrics-collection.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

':' expected.
name: 'cache_hit_rate_percentage',
help: 'Cache hit rate percentage',
labelNames: ['cache_type'],
registers: [this.registry],
});

Check failure on line 98 in src/monitoring/metrics/metrics-collection.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

',' expected.

// Queue Processing Time Histogram
this.queueProcessingTime = new Histogram({

Check failure on line 101 in src/monitoring/metrics/metrics-collection.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

':' expected.
name: 'queue_processing_duration_seconds',
help: 'Duration of queue job processing in seconds',
labelNames: ['queue_name', 'job_type'],
buckets: [0.1, 0.5, 1, 2, 5, 10, 30],
registers: [this.registry],
});

Check failure on line 107 in src/monitoring/metrics/metrics-collection.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

',' expected.

// Email Campaigns Sent Counter
this.emailCampaignsSent = new Counter({
Expand Down
Loading