b9f3de80f5
The metrics INFRA is already deployed (VictoriaMetrics scraping docmost:9464,
Grafana dashboards, alerts) with a target `gitmost-app` that is red because the
app half didn't exist. This is that half. The contract (metric names, port,
table, endpoint) is FIXED by the deployed infra and matched exactly.
Server (prom-client):
- A bare node:http `/metrics` server on METRICS_PORT (default 9464), SEPARATE
from the Fastify :3000 listener so /metrics never exists publicly; the whole
subsystem is OFF when METRICS_PORT is unset.
- collectDefaultMetrics() + http_request_duration_seconds{method,route,status}
via a Fastify onResponse hook using the ROUTE TEMPLATE (req.routeOptions.url,
never the raw URL — bounded cardinality; 404 -> "unknown"), EXCLUDING SSE/
streaming responses (would record the connection lifetime and poison p95).
- db_query_duration_seconds (Kysely log callback, labelled by the leading SQL
token), bullmq_queue_depth{queue} (getJobCounts every 15s) +
bullmq_job_duration_seconds{queue} (worker completed/failed),
collab_store_duration_seconds (around onStoreDocument).
- POST /api/telemetry/vitals — PUBLIC (sendBeacon) but IP-throttled; ~16KB body
cap, <=50 events/batch, metric-name + rating whitelist, attr truncated to 120
chars, batch insert; malformed/foreign/oversized silently dropped and 200'd (no
browser retry). New migration `client_metrics` (schema byte-identical to the
contract, both indexes, conditional grafana_ro GRANT; no app-side retention —
the maintenance container prunes >90d).
Client (web-vitals):
- initVitals() decides sampling ONCE per session (25%, sessionStorage) BEFORE
subscribing; onINP/onLCP/onCLS/onTTFB (attribution) buffered + flushed via
navigator.sendBeacon on visibilitychange:hidden and a timer (not fetch-per-
metric). Custom: editor_tx_ms (dispatchTransaction sync-part timer, >8ms, with
doc_size), page_open_ms, longtask_ms. Route labels are templates only; no
titles/slugs/text.
Gate: server + client tsc 0, frozen install 0 (added prom-client + web-vitals +
regenerated the lock), server metrics/vitals tests 11, client route-template 5,
and the migration verified valid against real Postgres.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
56 lines
2.3 KiB
TypeScript
56 lines
2.3 KiB
TypeScript
import { Module } from '@nestjs/common';
|
|
import { ThrottlerModule } from '@nestjs/throttler';
|
|
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
|
|
import { EnvironmentService } from '../environment/environment.service';
|
|
import { EnvironmentModule } from '../environment/environment.module';
|
|
import { parseRedisUrl } from '../../common/helpers';
|
|
import {
|
|
AUTH_THROTTLER,
|
|
AI_CHAT_THROTTLER,
|
|
PAGE_TEMPLATE_THROTTLER,
|
|
PUBLIC_SHARE_AI_THROTTLER,
|
|
VITALS_THROTTLER,
|
|
} from './throttler-names';
|
|
|
|
@Module({
|
|
imports: [
|
|
ThrottlerModule.forRootAsync({
|
|
imports: [EnvironmentModule],
|
|
useFactory: (environmentService: EnvironmentService) => {
|
|
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
|
|
|
|
return {
|
|
throttlers: [
|
|
{ name: AUTH_THROTTLER, ttl: 60_000, limit: 10 },
|
|
{ name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 },
|
|
// Whole-page template lookup returns full ProseMirror docs for up
|
|
// to 50 ids per call and the embed depth cap is client-side only, so
|
|
// a scripted client could drive heavy content fan-out. 30 req/min
|
|
// per user is plenty for legitimate render-time batched lookups.
|
|
{ name: PAGE_TEMPLATE_THROTTLER, ttl: 60_000, limit: 30 },
|
|
// Anonymous public-share assistant: ~5 req/min per IP.
|
|
{ name: PUBLIC_SHARE_AI_THROTTLER, ttl: 60_000, limit: 5 },
|
|
// Anonymous client perf-telemetry sink: 120 batched posts/min per IP.
|
|
{ name: VITALS_THROTTLER, ttl: 60_000, limit: 120 },
|
|
],
|
|
errorMessage: 'Too many requests',
|
|
// Pass ioredis options (not a pre-built Redis instance) so
|
|
// ThrottlerStorageRedisService owns the connection and disconnects it
|
|
// in its onModuleDestroy. Passing an instance leaves disconnectRequired
|
|
// false, so the socket would leak on shutdown (e2e jest never exits).
|
|
storage: new ThrottlerStorageRedisService({
|
|
host: redisConfig.host,
|
|
port: redisConfig.port,
|
|
password: redisConfig.password,
|
|
db: redisConfig.db,
|
|
family: redisConfig.family,
|
|
keyPrefix: 'throttle:',
|
|
}),
|
|
};
|
|
},
|
|
inject: [EnvironmentService],
|
|
}),
|
|
],
|
|
})
|
|
export class ThrottleModule {}
|