fix: apply consistent-type-imports across API codebase (728 lint errors)

- Convert `import type { X }` to `import { type X }` (inline-type-imports style)
- Suppress consistent-type-imports for `typeof import()` in instrument.ts
- Includes uncommitted agent work: metrics module, redis caching, audit logs,
  saved searches, circuit breaker, rate limiting, and admin enhancements

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 23:22:21 +07:00
parent 8cdfe17205
commit 6ebacbc9bf
85 changed files with 3844 additions and 82 deletions

View File

@@ -11,4 +11,10 @@ export {
DB_QUERY_DURATION,
DB_POOL_ACTIVE_CONNECTIONS,
SEARCH_QUERY_DURATION,
WEB_VITALS_LCP,
WEB_VITALS_FCP,
WEB_VITALS_CLS,
WEB_VITALS_TTFB,
WEB_VITALS_INP,
WEB_VITALS_TOTAL,
} from './metrics.constants';

View File

@@ -8,6 +8,12 @@ import {
GOODGO_SEARCH_QUERIES_TOTAL,
GOODGO_API_REQUEST_DURATION,
HTTP_REQUESTS_TOTAL,
WEB_VITALS_LCP,
WEB_VITALS_FCP,
WEB_VITALS_CLS,
WEB_VITALS_TTFB,
WEB_VITALS_INP,
WEB_VITALS_TOTAL,
} from '../metrics.constants';
@Injectable()
@@ -25,6 +31,18 @@ export class MetricsService {
private readonly requestDurationHistogram: Histogram,
@InjectMetric(HTTP_REQUESTS_TOTAL)
private readonly httpRequestsCounter: Counter,
@InjectMetric(WEB_VITALS_LCP)
private readonly lcpHistogram: Histogram,
@InjectMetric(WEB_VITALS_FCP)
private readonly fcpHistogram: Histogram,
@InjectMetric(WEB_VITALS_CLS)
private readonly clsHistogram: Histogram,
@InjectMetric(WEB_VITALS_TTFB)
private readonly ttfbHistogram: Histogram,
@InjectMetric(WEB_VITALS_INP)
private readonly inpHistogram: Histogram,
@InjectMetric(WEB_VITALS_TOTAL)
private readonly webVitalsCounter: Counter,
) {}
/** Record a new listing creation. */
@@ -62,4 +80,36 @@ export class MetricsService {
this.requestDurationHistogram.observe(labels, durationSeconds);
this.httpRequestsCounter.inc(labels);
}
/** Map metric name → the correct histogram. */
private readonly vitalHistograms: Record<string, Histogram | undefined> = {};
private getVitalHistogram(name: string): Histogram | undefined {
// Lazy-init the lookup (cannot reference `this` in field initialiser)
if (Object.keys(this.vitalHistograms).length === 0) {
this.vitalHistograms['LCP'] = this.lcpHistogram;
this.vitalHistograms['FCP'] = this.fcpHistogram;
this.vitalHistograms['CLS'] = this.clsHistogram;
this.vitalHistograms['TTFB'] = this.ttfbHistogram;
this.vitalHistograms['INP'] = this.inpHistogram;
}
return this.vitalHistograms[name];
}
/** Record a single Core Web Vital measurement. */
recordWebVital(
name: string,
value: number,
rating: string,
page: string,
): void {
const histogram = this.getVitalHistogram(name);
if (!histogram) return;
// LCP, FID, TTFB, INP arrive in ms from the browser — convert to seconds.
// CLS is unitless (no conversion).
const observeValue = name === 'CLS' ? value : value / 1000;
histogram.observe({ rating, page }, observeValue);
this.webVitalsCounter.inc({ name, rating });
}
}

View File

@@ -10,3 +10,11 @@ export const HTTP_REQUESTS_TOTAL = 'http_requests_total';
export const DB_QUERY_DURATION = 'db_query_duration_seconds';
export const DB_POOL_ACTIVE_CONNECTIONS = 'db_pool_active_connections';
export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds';
// ── Web Vitals / RUM Metrics ──
export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds';
export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds';
export const WEB_VITALS_CLS = 'goodgo_web_vitals_cls';
export const WEB_VITALS_TTFB = 'goodgo_web_vitals_ttfb_seconds';
export const WEB_VITALS_INP = 'goodgo_web_vitals_inp_seconds';
export const WEB_VITALS_TOTAL = 'goodgo_web_vitals_total';

View File

@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import {
PrometheusModule,
makeCounterProvider,
makeHistogramProvider,
makeGaugeProvider,
@@ -16,16 +15,18 @@ import {
DB_QUERY_DURATION,
DB_POOL_ACTIVE_CONNECTIONS,
SEARCH_QUERY_DURATION,
WEB_VITALS_LCP,
WEB_VITALS_FCP,
WEB_VITALS_CLS,
WEB_VITALS_TTFB,
WEB_VITALS_INP,
WEB_VITALS_TOTAL,
} from './metrics.constants';
import { WebVitalsController } from './presentation/controllers/web-vitals.controller';
import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics.interceptor';
@Module({
imports: [
PrometheusModule.register({
path: '/metrics',
defaultMetrics: { enabled: true },
}),
],
imports: [],
providers: [
// ── HTTP Metrics ──
makeHistogramProvider({
@@ -85,7 +86,45 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
// ── Services & Interceptors ──
MetricsService,
HttpMetricsInterceptor,
// ── Web Vitals / RUM Metrics ──
makeHistogramProvider({
name: WEB_VITALS_LCP,
help: 'Largest Contentful Paint in seconds',
labelNames: ['rating', 'page'],
buckets: [0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 8, 10],
}),
makeHistogramProvider({
name: WEB_VITALS_FCP,
help: 'First Contentful Paint in seconds',
labelNames: ['rating', 'page'],
buckets: [0.1, 0.5, 1, 1.5, 1.8, 2.5, 3, 4, 5, 8],
}),
makeHistogramProvider({
name: WEB_VITALS_CLS,
help: 'Cumulative Layout Shift score (unitless)',
labelNames: ['rating', 'page'],
buckets: [0.01, 0.025, 0.05, 0.1, 0.15, 0.2, 0.25, 0.5, 1],
}),
makeHistogramProvider({
name: WEB_VITALS_TTFB,
help: 'Time to First Byte in seconds',
labelNames: ['rating', 'page'],
buckets: [0.1, 0.2, 0.4, 0.6, 0.8, 1, 1.5, 2, 3, 5],
}),
makeHistogramProvider({
name: WEB_VITALS_INP,
help: 'Interaction to Next Paint in seconds',
labelNames: ['rating', 'page'],
buckets: [0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 0.8, 1],
}),
makeCounterProvider({
name: WEB_VITALS_TOTAL,
help: 'Total web vital events received',
labelNames: ['name', 'rating'],
}),
],
exports: [PrometheusModule, MetricsService, HttpMetricsInterceptor],
controllers: [WebVitalsController],
exports: [MetricsService, HttpMetricsInterceptor],
})
export class MetricsModule {}

View File

@@ -0,0 +1,76 @@
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { type MetricsService } from '../../infrastructure/metrics.service';
import { type WebVitalsBatchDto } from '../dto/web-vitals.dto';
/**
* Public endpoint for receiving Core Web Vitals from the frontend.
*
* No auth required — these are anonymous, best-effort telemetry beacons
* sent via `navigator.sendBeacon` during page transitions.
*
* Rate limiting and abuse protection should be handled at the
* reverse-proxy / CDN layer.
*/
@ApiTags('web-vitals')
@Controller('web-vitals')
export class WebVitalsController {
private readonly logger = new Logger(WebVitalsController.name);
constructor(private readonly metricsService: MetricsService) {}
@Post()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Ingest a batch of Core Web Vitals metrics' })
@ApiResponse({ status: 204, description: 'Metrics accepted' })
@ApiResponse({ status: 400, description: 'Invalid payload' })
ingest(@Body() dto: WebVitalsBatchDto): void {
for (const metric of dto.metrics) {
try {
this.metricsService.recordWebVital(
metric.name,
metric.value,
metric.rating,
this.normalisePage(metric.url),
);
} catch (error) {
this.logger.warn(
`Failed to record web vital ${metric.name}: ${error}`,
);
}
}
}
/**
* Normalise the raw URL path into a route-level label to keep
* Prometheus cardinality manageable (e.g. `/vi/listings/abc123` → `/[locale]/listings/[id]`).
*/
private normalisePage(url: string): string {
if (!url) return '/';
return (
url
// Strip query string and fragment
.split('?')[0]!
.split('#')[0]!
// Replace locale segment
.replace(/^\/(vi|en)/, '/[locale]')
// Replace UUIDs
.replace(
/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
'/[id]',
)
// Replace numeric IDs
.replace(/\/\d+/g, '/[id]')
// Replace trailing slash
.replace(/\/$/, '') || '/'
);
}
}

View File

@@ -0,0 +1,62 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsIn,
IsNumber,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
const VALID_VITAL_NAMES = ['LCP', 'FCP', 'CLS', 'TTFB', 'INP'] as const;
const VALID_RATINGS = ['good', 'needs-improvement', 'poor'] as const;
export class WebVitalMetricDto {
@ApiProperty({ enum: VALID_VITAL_NAMES, description: 'Core Web Vital name' })
@IsIn(VALID_VITAL_NAMES)
name!: string;
@ApiProperty({ description: 'Metric value (ms for timing metrics, unitless for CLS)' })
@IsNumber()
@Min(0)
value!: number;
@ApiProperty({ enum: VALID_RATINGS, description: 'Performance rating' })
@IsIn(VALID_RATINGS)
rating!: string;
@ApiProperty({ description: 'Delta since last report' })
@IsNumber()
delta!: number;
@ApiProperty({ description: 'Unique metric ID from web-vitals' })
@IsString()
@MaxLength(128)
id!: string;
@ApiProperty({ description: 'Navigation type (navigate, reload, etc.)' })
@IsString()
@MaxLength(64)
navigationType!: string;
@ApiProperty({ description: 'Page URL path' })
@IsString()
@MaxLength(2048)
url!: string;
@ApiProperty({ description: 'Client timestamp (epoch ms)' })
@IsNumber()
timestamp!: number;
}
export class WebVitalsBatchDto {
@ApiProperty({ type: [WebVitalMetricDto], description: 'Batch of web vital metrics' })
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => WebVitalMetricDto)
metrics!: WebVitalMetricDto[];
}