Skip to content

Commit c48022d

Browse files
Merge pull request #1459 from Tarrii/tarrii/userapi-1351-1354
feat(users): Prometheus metrics, query optimisation, Redis caching, per-caller rate limits
2 parents 9f5de21 + 2dd50e2 commit c48022d

10 files changed

Lines changed: 231 additions & 10 deletions

‎docs/api/users-caching.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Users API Redis caching
2+
3+
`UsersService.getUserStatistics` (`GET /users/me/statistics`) is cached per user for 60 seconds through `UsersCacheService`, which uses the shared `CacheService` (Redis when configured).
4+
5+
- Key: `users:statistics:<userId>`; TTL 60s, so counts can be up to a minute stale.
6+
- Read-through only: cache errors, or no cache being available, fall back to the database, so caching can never fail a request.
7+
- Profile, role and permission data is deliberately **not** cached, because changes to it have to be visible immediately.

‎docs/api/users-db-optimization.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Users API database query optimisation
2+
3+
- **`GET /users/me/statistics`** loaded the user together with every property and every buyer/seller transaction row just to report their lengths. It now selects two columns plus Prisma `_count` aggregates, so the database counts and no rows are transferred.
4+
- **`GET /users/me/login-history`** returned the entire history, which grows without bound. It now returns the newest 100 entries (same ordering and fields).

‎docs/api/users-metrics.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Users API Prometheus metrics
2+
3+
Exposed on the existing `/metrics` endpoint:
4+
5+
| Metric | Type | Labels |
6+
| --- | --- | --- |
7+
| `users_requests_total` | counter | `operation` (controller handler name), `outcome` (`success`, `client_error`, `server_error`) |
8+
| `users_request_duration_seconds` | histogram | `operation` |
9+
10+
`UsersMetricsInterceptor` records both for every `UsersController` handler. Labels are bounded (a fixed set of handler names and three outcomes), so cardinality does not grow with user input.

‎docs/api/users-rate-limiting.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Users API rate limiting
2+
3+
`UsersRouteRateLimitGuard` + `@UsersRouteLimit({ max, windowMs })` limit costly or abusable routes per caller (the authenticated user id, falling back to the client address). Each route has its own counter.
4+
5+
| Route | Limit |
6+
| --- | --- |
7+
| `POST /users` (admin) | 10 per hour |
8+
| `GET /users/search` (admin) | 60 per minute |
9+
| `GET /users/me/statistics` | 30 per minute |
10+
| `GET /users/me/login-history` | 30 per minute |
11+
12+
Over the limit the response is `429` with a `Retry-After` header. The guard runs after the JWT guard so the caller is known. Existing in-controller limits (data export download, reactivation) are unchanged.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
HttpException,
5+
HttpStatus,
6+
Injectable,
7+
SetMetadata,
8+
} from '@nestjs/common';
9+
import { Reflector } from '@nestjs/core';
10+
import { RateLimitService } from '../../auth/rate-limit.service';
11+
12+
export const USERS_ROUTE_LIMIT_KEY = 'users-route-limit';
13+
14+
export interface UsersRouteLimitOptions {
15+
/** Requests allowed per caller per window. */
16+
max: number;
17+
windowMs: number;
18+
}
19+
20+
/** Limit one Users route per authenticated caller. Apply after the JWT guard. */
21+
export const UsersRouteLimit = (options: UsersRouteLimitOptions) =>
22+
SetMetadata(USERS_ROUTE_LIMIT_KEY, options);
23+
24+
/**
25+
* Per-caller rate limit for costly or abusable Users routes. The caller is the
26+
* authenticated user id (falling back to the client address), and each route has
27+
* its own counter, so heavy use of one route does not consume another's budget.
28+
* Over the limit the response is `429` with `Retry-After`.
29+
*/
30+
@Injectable()
31+
export class UsersRouteRateLimitGuard implements CanActivate {
32+
constructor(
33+
private readonly reflector: Reflector,
34+
private readonly rateLimitService: RateLimitService,
35+
) {}
36+
37+
async canActivate(context: ExecutionContext): Promise<boolean> {
38+
const options = this.reflector.get<UsersRouteLimitOptions | undefined>(
39+
USERS_ROUTE_LIMIT_KEY,
40+
context.getHandler(),
41+
);
42+
if (!options) return true;
43+
44+
const request = context.switchToHttp().getRequest();
45+
const response = context.switchToHttp().getResponse();
46+
const route = `${request.method} ${request.route?.path ?? request.url}`;
47+
const caller = String(request.authUser?.sub ?? request.user?.sub ?? request.ip ?? 'unknown');
48+
49+
const status = await this.rateLimitService.checkScopedRateLimit(
50+
`users-route:${route}`,
51+
caller,
52+
options.max,
53+
options.windowMs,
54+
);
55+
56+
if (status.isExceeded) {
57+
if (status.retryAfter !== undefined) response.setHeader('Retry-After', String(status.retryAfter));
58+
throw new HttpException(
59+
{
60+
statusCode: HttpStatus.TOO_MANY_REQUESTS,
61+
message: 'Too many requests. Please try again later.',
62+
retryAfter: status.retryAfter,
63+
},
64+
HttpStatus.TOO_MANY_REQUESTS,
65+
);
66+
}
67+
return true;
68+
}
69+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
2+
import { Observable } from 'rxjs';
3+
import { tap } from 'rxjs/operators';
4+
import { usersOutcomeForStatus, usersRequestDurationSeconds, usersRequestsTotal } from '../users-metrics';
5+
6+
/** Records `users_requests_total` and `users_request_duration_seconds` for each handler call. */
7+
@Injectable()
8+
export class UsersMetricsInterceptor implements NestInterceptor {
9+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
10+
const operation = context.getHandler().name;
11+
const end = usersRequestDurationSeconds.startTimer({ operation });
12+
13+
const record = (status: number): void => {
14+
end();
15+
usersRequestsTotal.inc({ operation, outcome: usersOutcomeForStatus(status) });
16+
};
17+
18+
return next.handle().pipe(
19+
tap({
20+
next: () => record(context.switchToHttp().getResponse().statusCode ?? 200),
21+
error: (err: { status?: number; getStatus?: () => number }) =>
22+
record(typeof err?.getStatus === 'function' ? err.getStatus() : (err?.status ?? 500)),
23+
}),
24+
);
25+
}
26+
}

‎src/users/users-cache.service.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Injectable, Optional } from '@nestjs/common';
2+
import { CacheService } from '../cache/cache.service';
3+
import { CacheOperationStatus } from '../cache/cache.types';
4+
5+
export const USER_STATISTICS_TTL_SECONDS = 60;
6+
7+
/**
8+
* Read-through Redis cache for expensive, read-only Users API responses.
9+
* Cache failures never fail a request: on any cache error, or when no cache is
10+
* available, the loader runs as if caching were off.
11+
*/
12+
@Injectable()
13+
export class UsersCacheService {
14+
constructor(@Optional() private readonly cache?: CacheService) {}
15+
16+
statisticsKey(userId: string): string {
17+
return `users:statistics:${userId}`;
18+
}
19+
20+
async remember<T>(key: string, ttlSeconds: number, loader: () => Promise<T>): Promise<T> {
21+
if (!this.cache) return loader();
22+
try {
23+
const hit = await this.cache.get<T>(key);
24+
if (hit.status === CacheOperationStatus.SUCCESS && hit.data !== undefined) return hit.data as T;
25+
} catch {
26+
return loader();
27+
}
28+
const value = await loader();
29+
try {
30+
await this.cache.set(key, value, ttlSeconds);
31+
} catch {
32+
/* a failed write only means the next call recomputes */
33+
}
34+
return value;
35+
}
36+
37+
async invalidateStatistics(userId: string): Promise<void> {
38+
try {
39+
await this.cache?.del(this.statisticsKey(userId));
40+
} catch {
41+
/* the entry expires on its own within the TTL */
42+
}
43+
}
44+
}

‎src/users/users-metrics.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Counter, Histogram, register } from 'prom-client';
2+
3+
/**
4+
* Prometheus metrics for the Users API. Labels are bounded: `operation` is a
5+
* controller handler name and `outcome` one of three values, so cardinality
6+
* cannot grow with user input. Metrics are looked up before being created so hot
7+
* reloads and test suites do not register them twice.
8+
*/
9+
export type UsersOutcome = 'success' | 'client_error' | 'server_error';
10+
11+
export const usersRequestsTotal: Counter<'operation' | 'outcome'> =
12+
(register.getSingleMetric('users_requests_total') as Counter<'operation' | 'outcome'>) ??
13+
new Counter({
14+
name: 'users_requests_total',
15+
help: 'Users API requests by handler and outcome',
16+
labelNames: ['operation', 'outcome'],
17+
});
18+
19+
export const usersRequestDurationSeconds: Histogram<'operation'> =
20+
(register.getSingleMetric('users_request_duration_seconds') as Histogram<'operation'>) ??
21+
new Histogram({
22+
name: 'users_request_duration_seconds',
23+
help: 'Users API handler latency in seconds',
24+
labelNames: ['operation'],
25+
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
26+
});
27+
28+
export function usersOutcomeForStatus(status: number): UsersOutcome {
29+
if (status >= 500) return 'server_error';
30+
if (status >= 400) return 'client_error';
31+
return 'success';
32+
}

‎src/users/users.controller.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ export class UsersController {
127127

128128
// ─── Admin Endpoints ─────────────────────────────────────────────
129129

130-
@UseGuards(JwtAuthGuard, RolesGuard)
130+
@UseGuards(JwtAuthGuard, RolesGuard, UsersRouteRateLimitGuard)
131+
@UsersRouteLimit({ max: 10, windowMs: 60 * 60 * 1000 })
131132
@Roles(UserRole.ADMIN)
132133
@Post()
133134
create(@Body(UserInjectionGuardPipe) createUserDto: CreateUserDto) {
@@ -141,14 +142,16 @@ export class UsersController {
141142
return this.usersService.findAll(query);
142143
}
143144

144-
@UseGuards(JwtAuthGuard, RolesGuard)
145+
@UseGuards(JwtAuthGuard, RolesGuard, UsersRouteRateLimitGuard)
146+
@UsersRouteLimit({ max: 60, windowMs: 60 * 1000 })
145147
@Roles(UserRole.ADMIN)
146148
@Get('search')
147149
search(@Query(UserInjectionGuardPipe) query: SearchUsersDto) {
148150
return this.usersService.search(query);
149151
}
150152

151-
@UseGuards(JwtAuthGuard)
153+
@UseGuards(JwtAuthGuard, UsersRouteRateLimitGuard)
154+
@UsersRouteLimit({ max: 30, windowMs: 60 * 1000 })
152155
@Get('me/statistics')
153156
getStatistics(@CurrentUser() user: AuthUserPayload) {
154157
return this.usersService.getUserStatistics(user.sub);
@@ -392,7 +395,8 @@ export class UsersController {
392395
return this.usersService.getMyReferrals(user.sub);
393396
}
394397

395-
@UseGuards(JwtAuthGuard)
398+
@UseGuards(JwtAuthGuard, UsersRouteRateLimitGuard)
399+
@UsersRouteLimit({ max: 30, windowMs: 60 * 1000 })
396400
@Get('me/login-history')
397401
getLoginHistory(@CurrentUser() user: AuthUserPayload) {
398402
return this.usersService.getLoginHistory(user.sub);

‎src/users/users.service.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Injectable,
33
Logger,
44
OnModuleInit,
5+
Optional,
56
NotFoundException,
67
BadRequestException,
78
} from '@nestjs/common';
@@ -33,6 +34,7 @@ export class UsersService implements OnModuleInit {
3334
constructor(
3435
private readonly prisma: PrismaService,
3536
private readonly sessionsService: SessionsService,
37+
@Optional() private readonly usersCache?: UsersCacheService,
3638
) {}
3739

3840
async getProfile(userId: string): Promise<ProfileResponseDto> {
@@ -210,22 +212,31 @@ export class UsersService implements OnModuleInit {
210212
});
211213
}
212214

215+
/** Cached for 60 seconds per user; statistics may be that stale. Cache failures fall back to the database. */
213216
async getUserStatistics(userId: string) {
217+
if (!this.usersCache) return this.loadUserStatistics(userId);
218+
return this.usersCache.remember(this.usersCache.statisticsKey(userId), USER_STATISTICS_TTL_SECONDS, () =>
219+
this.loadUserStatistics(userId),
220+
);
221+
}
222+
223+
private async loadUserStatistics(userId: string) {
224+
// Count in the database instead of loading every property and transaction row.
214225
const user = await this.prisma.user.findUnique({
215226
where: { id: userId },
216-
include: {
217-
properties: true,
218-
buyerTransactions: true,
219-
sellerTransactions: true,
227+
select: {
228+
createdAt: true,
229+
lastActivityAt: true,
230+
_count: { select: { properties: true, buyerTransactions: true, sellerTransactions: true } },
220231
},
221232
});
222233

223234
if (!user) {
224235
throw new NotFoundException('User not found');
225236
}
226237

227-
const propertiesCount = user.properties.length;
228-
const transactionsCount = user.buyerTransactions.length + user.sellerTransactions.length;
238+
const propertiesCount = user._count.properties;
239+
const transactionsCount = user._count.buyerTransactions + user._count.sellerTransactions;
229240

230241
const now = new Date();
231242
const createdAt = new Date(user.createdAt);
@@ -904,6 +915,8 @@ export class UsersService implements OnModuleInit {
904915
orderBy: {
905916
timestamp: 'desc',
906917
},
918+
// Newest 100 entries: the history grows without bound, and one response should not.
919+
take: 100,
907920
select: {
908921
timestamp: true,
909922
ipAddress: true,

0 commit comments

Comments
 (0)