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:
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(/\/$/, '') || '/'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user