feat(observability): dev-side perf metrics — /metrics + client vitals (#355)
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>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isStreamingResponse } from './metrics.constants';
|
||||
import { observeHttp } from './metrics.registry';
|
||||
|
||||
/**
|
||||
* Resolve the BOUNDED route label for an HTTP response.
|
||||
*
|
||||
* HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER the raw
|
||||
* URL (`/pages/abc-123`), so label cardinality stays finite. Fastify exposes the
|
||||
* matched template on `req.routeOptions.url`. On 404s (no route matched) that is
|
||||
* missing → collapse to the literal `unknown`.
|
||||
*/
|
||||
export function resolveRouteLabel(req: FastifyRequest): string {
|
||||
const url = req.routeOptions?.url;
|
||||
return typeof url === 'string' && url.length > 0 ? url : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fastify onResponse handler that records http_request_duration_seconds.
|
||||
* No-op when metrics are disabled (the hook is only registered when enabled,
|
||||
* but the observe helpers are also guarded). Never throws into the response
|
||||
* pipeline — telemetry must not break request handling.
|
||||
*/
|
||||
export function recordHttpResponse(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): void {
|
||||
try {
|
||||
const route = resolveRouteLabel(req);
|
||||
|
||||
// Exclude SSE/streaming responses: onResponse fires at connection close for
|
||||
// those, so it would record the stream lifetime and poison p95/p99.
|
||||
const contentType = reply.getHeader('content-type');
|
||||
if (isStreamingResponse(contentType, route)) return;
|
||||
|
||||
observeHttp(
|
||||
req.method,
|
||||
route,
|
||||
reply.statusCode,
|
||||
// Fastify measures elapsed time in ms; the metric is in seconds.
|
||||
reply.elapsedTime / 1000,
|
||||
);
|
||||
} catch {
|
||||
// Swallow: a telemetry failure must never affect the served response.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue, QueueEvents } from 'bullmq';
|
||||
import { QueueName } from '../queue/constants';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { parseRedisUrl } from '../../common/helpers';
|
||||
import {
|
||||
isMetricsEnabled,
|
||||
observeJobDuration,
|
||||
setQueueDepth,
|
||||
} from './metrics.registry';
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
// Cap the in-flight start-time map so a job that never emits completed/failed
|
||||
// (worker crash) cannot leak memory unbounded. Well above realistic concurrency.
|
||||
const MAX_INFLIGHT = 10_000;
|
||||
|
||||
/**
|
||||
* BullMQ instrumentation for #355:
|
||||
* - `bullmq_queue_depth{queue}`: polled from getJobCounts() every 15s.
|
||||
* - `bullmq_job_duration_seconds{queue}`: wall-clock time between a job going
|
||||
* `active` and `completed`/`failed`, observed via per-queue QueueEvents.
|
||||
*
|
||||
* Queue names are a FINITE list (the QueueName enum), so labels are bounded — no
|
||||
* job ids ever enter a label. Everything is gated on METRICS_PORT: when metrics
|
||||
* are off, onModuleInit does nothing (no interval, no QueueEvents connections).
|
||||
*/
|
||||
@Injectable()
|
||||
export class MetricsBullService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MetricsBullService.name);
|
||||
private readonly queues: { label: string; queue: Queue }[];
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private queueEvents: QueueEvents[] = [];
|
||||
// jobId -> start timestamp (ms). Bounded by MAX_INFLIGHT.
|
||||
private readonly inflight = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectQueue(QueueName.EMAIL_QUEUE) emailQueue: Queue,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE) generalQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) billingQueue: Queue,
|
||||
@InjectQueue(QueueName.FILE_TASK_QUEUE) fileTaskQueue: Queue,
|
||||
@InjectQueue(QueueName.SEARCH_QUEUE) searchQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) aiQueue: Queue,
|
||||
@InjectQueue(QueueName.HISTORY_QUEUE) historyQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) notificationQueue: Queue,
|
||||
@InjectQueue(QueueName.AUDIT_QUEUE) auditQueue: Queue,
|
||||
) {
|
||||
this.queues = [
|
||||
{ label: 'email', queue: emailQueue },
|
||||
{ label: 'attachment', queue: attachmentQueue },
|
||||
{ label: 'general', queue: generalQueue },
|
||||
{ label: 'billing', queue: billingQueue },
|
||||
{ label: 'file-task', queue: fileTaskQueue },
|
||||
{ label: 'search', queue: searchQueue },
|
||||
{ label: 'ai', queue: aiQueue },
|
||||
{ label: 'history', queue: historyQueue },
|
||||
{ label: 'notification', queue: notificationQueue },
|
||||
{ label: 'audit', queue: auditQueue },
|
||||
];
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (!isMetricsEnabled()) return;
|
||||
|
||||
// Poll queue depth.
|
||||
this.timer = setInterval(() => {
|
||||
void this.pollDepths();
|
||||
}, POLL_INTERVAL_MS);
|
||||
// Do not keep the event loop alive solely for polling.
|
||||
this.timer.unref?.();
|
||||
void this.pollDepths();
|
||||
|
||||
// Wire per-queue job-duration events.
|
||||
const redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||
const connection = {
|
||||
host: redisConfig.host,
|
||||
port: redisConfig.port,
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
family: redisConfig.family,
|
||||
};
|
||||
|
||||
for (const { label, queue } of this.queues) {
|
||||
const events = new QueueEvents(queue.name, { connection });
|
||||
events.on('active', ({ jobId }) => {
|
||||
if (this.inflight.size >= MAX_INFLIGHT) {
|
||||
// Drop the oldest tracked start to keep the map bounded.
|
||||
const oldest = this.inflight.keys().next().value;
|
||||
if (oldest !== undefined) this.inflight.delete(oldest);
|
||||
}
|
||||
this.inflight.set(jobId, Date.now());
|
||||
});
|
||||
const finalize = ({ jobId }: { jobId: string }) => {
|
||||
const start = this.inflight.get(jobId);
|
||||
if (start === undefined) return;
|
||||
this.inflight.delete(jobId);
|
||||
observeJobDuration(label, (Date.now() - start) / 1000);
|
||||
};
|
||||
events.on('completed', finalize);
|
||||
events.on('failed', finalize);
|
||||
events.on('error', (err) => {
|
||||
this.logger.debug(`QueueEvents error (${label}): ${err?.message}`);
|
||||
});
|
||||
this.queueEvents.push(events);
|
||||
}
|
||||
}
|
||||
|
||||
private async pollDepths(): Promise<void> {
|
||||
for (const { label, queue } of this.queues) {
|
||||
try {
|
||||
const counts = await queue.getJobCounts();
|
||||
// "Depth" = jobs not yet finished (backlog + in-flight).
|
||||
const depth =
|
||||
(counts.waiting ?? 0) +
|
||||
(counts.active ?? 0) +
|
||||
(counts.delayed ?? 0) +
|
||||
(counts.prioritized ?? 0) +
|
||||
(counts.paused ?? 0);
|
||||
setQueueDepth(label, depth);
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
`Failed to read job counts for ${label}: ${(err as Error)?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
await Promise.all(
|
||||
this.queueEvents.map((e) => e.close().catch(() => undefined)),
|
||||
);
|
||||
this.queueEvents = [];
|
||||
this.inflight.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Perf-metrics contract (#355). These names/labels are FIXED by the already
|
||||
* deployed scrape+dashboard infra (VictoriaMetrics scraping docmost:9464,
|
||||
* Grafana dashboards, alerts). Do NOT rename them.
|
||||
*/
|
||||
export const METRIC_HTTP_REQUEST_DURATION = 'http_request_duration_seconds';
|
||||
export const METRIC_DB_QUERY_DURATION = 'db_query_duration_seconds';
|
||||
export const METRIC_BULLMQ_QUEUE_DEPTH = 'bullmq_queue_depth';
|
||||
export const METRIC_BULLMQ_JOB_DURATION = 'bullmq_job_duration_seconds';
|
||||
export const METRIC_COLLAB_STORE_DURATION = 'collab_store_duration_seconds';
|
||||
|
||||
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
|
||||
// for typical web/DB latencies without exploding series cardinality.
|
||||
export const HTTP_BUCKETS = [
|
||||
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
||||
];
|
||||
export const DB_BUCKETS = [
|
||||
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5,
|
||||
];
|
||||
export const COLLAB_BUCKETS = [
|
||||
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5,
|
||||
];
|
||||
export const JOB_BUCKETS = [
|
||||
0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120,
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract the first SQL token (select/insert/update/delete/...) from a query,
|
||||
* lower-cased, to use as a BOUNDED label for db_query_duration_seconds. Using
|
||||
* the full query text would blow up label cardinality; the leading keyword is a
|
||||
* finite set. Unknown/empty queries collapse to `other`.
|
||||
*/
|
||||
export function firstSqlToken(sql: string | undefined): string {
|
||||
if (!sql) return 'other';
|
||||
// Skip leading whitespace / comments and grab the first word.
|
||||
const match = /^[\s(]*([a-zA-Z]+)/.exec(sql);
|
||||
if (!match) return 'other';
|
||||
const token = match[1].toLowerCase();
|
||||
const known = new Set([
|
||||
'select',
|
||||
'insert',
|
||||
'update',
|
||||
'delete',
|
||||
'with',
|
||||
'begin',
|
||||
'commit',
|
||||
'rollback',
|
||||
'alter',
|
||||
'create',
|
||||
'drop',
|
||||
'truncate',
|
||||
'explain',
|
||||
]);
|
||||
return known.has(token) ? token : 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an HTTP response must be EXCLUDED from http_request_duration_seconds.
|
||||
*
|
||||
* SSE/streaming responses (the AI-chat `text/event-stream`) keep the connection
|
||||
* open for the whole conversation, so Fastify's onResponse fires only when the
|
||||
* client disconnects — recording the connection lifetime, not a response time,
|
||||
* which would poison p95/p99. We skip by content-type (authoritative) with a
|
||||
* route-suffix fallback for the two known stream endpoints.
|
||||
*/
|
||||
export function isStreamingResponse(
|
||||
contentType: unknown,
|
||||
route: string | undefined,
|
||||
): boolean {
|
||||
if (
|
||||
typeof contentType === 'string' &&
|
||||
contentType.toLowerCase().includes('text/event-stream')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Fallback: the AI-chat stream routes (/api/ai-chat/stream,
|
||||
// /api/shares/ai/stream) both end in `/stream`.
|
||||
if (route && route.endsWith('/stream')) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MetricsBullService } from './metrics-bull.service';
|
||||
|
||||
/**
|
||||
* Wires the BullMQ collectors (#355). The queues are provided by the @Global
|
||||
* QueueModule (which exports BullModule), so no re-registration is needed here.
|
||||
* The HTTP histogram, DB-query and collab-store collectors live in module-level
|
||||
* singletons (metrics.registry) and are wired directly at their call sites.
|
||||
*/
|
||||
@Module({
|
||||
providers: [MetricsBullService],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
collectDefaultMetrics,
|
||||
Histogram,
|
||||
Gauge,
|
||||
Registry,
|
||||
} from 'prom-client';
|
||||
import {
|
||||
COLLAB_BUCKETS,
|
||||
DB_BUCKETS,
|
||||
HTTP_BUCKETS,
|
||||
JOB_BUCKETS,
|
||||
METRIC_BULLMQ_JOB_DURATION,
|
||||
METRIC_BULLMQ_QUEUE_DEPTH,
|
||||
METRIC_COLLAB_STORE_DURATION,
|
||||
METRIC_DB_QUERY_DURATION,
|
||||
METRIC_HTTP_REQUEST_DURATION,
|
||||
} from './metrics.constants';
|
||||
|
||||
/**
|
||||
* Process-wide perf-metrics registry (#355).
|
||||
*
|
||||
* This is a plain module singleton (NOT a Nest provider) because the collectors
|
||||
* are cross-cutting: the Kysely `log` callback (built in a DI factory), the
|
||||
* Fastify onResponse hook (main.ts, before the Nest container hands out
|
||||
* providers) and the collab persistence extension all need the SAME instruments
|
||||
* without threading DI through them.
|
||||
*
|
||||
* HARD CONTRACT: when `METRICS_PORT` is unset the whole subsystem is OFF — the
|
||||
* registry is never created, `collectDefaultMetrics` never runs, and every
|
||||
* observe/set helper is a cheap no-op. Nothing is exposed on :3000.
|
||||
*/
|
||||
|
||||
// Decided once at process start. Deliberately read here (not via
|
||||
// EnvironmentService) so the toggle is identical for the DI and non-DI callers.
|
||||
const enabled = Boolean(process.env.METRICS_PORT);
|
||||
|
||||
let registry: Registry | null = null;
|
||||
let httpHist: Histogram<'method' | 'route' | 'status'> | null = null;
|
||||
let dbHist: Histogram<'op'> | null = null;
|
||||
let queueDepthGauge: Gauge<'queue'> | null = null;
|
||||
let jobHist: Histogram<'queue'> | null = null;
|
||||
let collabHist: Histogram | null = null;
|
||||
|
||||
function init(): void {
|
||||
if (registry || !enabled) return;
|
||||
|
||||
registry = new Registry();
|
||||
|
||||
// Node/runtime metrics: gives nodejs_eventloop_lag_p99_seconds, GC, heap, etc.
|
||||
collectDefaultMetrics({ register: registry });
|
||||
|
||||
httpHist = new Histogram({
|
||||
name: METRIC_HTTP_REQUEST_DURATION,
|
||||
help: 'HTTP request duration in seconds, by method, route template and status',
|
||||
labelNames: ['method', 'route', 'status'],
|
||||
buckets: HTTP_BUCKETS,
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
dbHist = new Histogram({
|
||||
name: METRIC_DB_QUERY_DURATION,
|
||||
help: 'Database query duration in seconds, by leading SQL keyword',
|
||||
labelNames: ['op'],
|
||||
buckets: DB_BUCKETS,
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
queueDepthGauge = new Gauge({
|
||||
name: METRIC_BULLMQ_QUEUE_DEPTH,
|
||||
help: 'Number of not-yet-finished BullMQ jobs per queue',
|
||||
labelNames: ['queue'],
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
jobHist = new Histogram({
|
||||
name: METRIC_BULLMQ_JOB_DURATION,
|
||||
help: 'BullMQ job processing duration in seconds, per queue',
|
||||
labelNames: ['queue'],
|
||||
buckets: JOB_BUCKETS,
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
collabHist = new Histogram({
|
||||
name: METRIC_COLLAB_STORE_DURATION,
|
||||
help: 'Collaboration onStoreDocument duration in seconds',
|
||||
buckets: COLLAB_BUCKETS,
|
||||
registers: [registry],
|
||||
});
|
||||
}
|
||||
|
||||
// Runs once when this module is first imported. Safe to call again (idempotent).
|
||||
init();
|
||||
|
||||
export function isMetricsEnabled(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** The prom-client registry, or null when metrics are disabled. */
|
||||
export function getMetricsRegistry(): Registry | null {
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function observeHttp(
|
||||
method: string,
|
||||
route: string,
|
||||
status: number,
|
||||
seconds: number,
|
||||
): void {
|
||||
httpHist?.observe({ method, route, status }, seconds);
|
||||
}
|
||||
|
||||
export function observeDbQuery(op: string, seconds: number): void {
|
||||
dbHist?.observe({ op }, seconds);
|
||||
}
|
||||
|
||||
export function setQueueDepth(queue: string, depth: number): void {
|
||||
queueDepthGauge?.set({ queue }, depth);
|
||||
}
|
||||
|
||||
export function observeJobDuration(queue: string, seconds: number): void {
|
||||
jobHist?.observe({ queue }, seconds);
|
||||
}
|
||||
|
||||
export function observeCollabStore(seconds: number): void {
|
||||
collabHist?.observe(seconds);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createServer, Server } from 'node:http';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { getMetricsRegistry, isMetricsEnabled } from './metrics.registry';
|
||||
|
||||
/**
|
||||
* Start the Prometheus scrape endpoint on a SEPARATE port (default 9464,
|
||||
* overridable via `METRICS_PORT`). This is a bare node:http server, NOT part of
|
||||
* the Fastify app, so `/metrics` never exists on the public :3000 listener.
|
||||
*
|
||||
* Returns the http.Server (so callers can close it on shutdown) or null when
|
||||
* metrics are disabled.
|
||||
*/
|
||||
export function startMetricsServer(): Server | null {
|
||||
if (!isMetricsEnabled()) return null;
|
||||
|
||||
const logger = new Logger('MetricsServer');
|
||||
const register = getMetricsRegistry();
|
||||
if (!register) return null;
|
||||
|
||||
const port = Number(process.env.METRICS_PORT);
|
||||
if (!Number.isInteger(port) || port <= 0) {
|
||||
logger.warn(
|
||||
`Invalid METRICS_PORT="${process.env.METRICS_PORT}", metrics endpoint not started`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/metrics') {
|
||||
try {
|
||||
const body = await register.metrics();
|
||||
res.setHeader('Content-Type', register.contentType);
|
||||
res.statusCode = 200;
|
||||
res.end(body);
|
||||
} catch (err) {
|
||||
res.statusCode = 500;
|
||||
res.end(String((err as Error)?.message ?? 'error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
|
||||
// Bind on all interfaces: the scraper (VictoriaMetrics) reaches this from
|
||||
// another container as docmost:9464. The port is not published to the host.
|
||||
server.listen(port, '0.0.0.0', () => {
|
||||
logger.log(`Metrics endpoint listening on :${port}/metrics`);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
logger.error(`Metrics server error: ${err?.message}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { resolveRouteLabel } from './http-metrics.hook';
|
||||
import { firstSqlToken, isStreamingResponse } from './metrics.constants';
|
||||
|
||||
describe('resolveRouteLabel (histogram route label)', () => {
|
||||
it('uses the ROUTE TEMPLATE, never the raw URL', () => {
|
||||
// routeOptions.url is the matched template; url is the raw path with the id.
|
||||
const req = {
|
||||
url: '/api/pages/abc-123-def',
|
||||
routeOptions: { url: '/api/pages/:id' },
|
||||
} as unknown as FastifyRequest;
|
||||
expect(resolveRouteLabel(req)).toBe('/api/pages/:id');
|
||||
expect(resolveRouteLabel(req)).not.toContain('abc-123-def');
|
||||
});
|
||||
|
||||
it('falls back to "unknown" on a 404 (no matched route template)', () => {
|
||||
const req = {
|
||||
url: '/totally/unmatched/path',
|
||||
routeOptions: {},
|
||||
} as unknown as FastifyRequest;
|
||||
expect(resolveRouteLabel(req)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('falls back to "unknown" when routeOptions is missing', () => {
|
||||
const req = { url: '/x' } as unknown as FastifyRequest;
|
||||
expect(resolveRouteLabel(req)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStreamingResponse (SSE exclusion)', () => {
|
||||
it('excludes text/event-stream responses by content-type', () => {
|
||||
expect(isStreamingResponse('text/event-stream', '/api/ai-chat/stream')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isStreamingResponse('text/event-stream; charset=utf-8', '/x')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes known /stream routes by suffix as a fallback', () => {
|
||||
expect(isStreamingResponse('application/json', '/api/ai-chat/stream')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isStreamingResponse(undefined, '/api/shares/ai/stream')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not exclude ordinary JSON responses', () => {
|
||||
expect(isStreamingResponse('application/json', '/api/pages/:id')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isStreamingResponse(undefined, '/api/pages/:id')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstSqlToken (bounded db label)', () => {
|
||||
it('returns the lower-cased leading keyword', () => {
|
||||
expect(firstSqlToken('SELECT * FROM pages')).toBe('select');
|
||||
expect(firstSqlToken(' insert into x values (1)')).toBe('insert');
|
||||
expect(firstSqlToken('UPDATE pages SET a=1')).toBe('update');
|
||||
expect(firstSqlToken('delete from pages')).toBe('delete');
|
||||
expect(firstSqlToken('(SELECT 1)')).toBe('select');
|
||||
});
|
||||
|
||||
it('collapses unknown/empty queries to "other"', () => {
|
||||
expect(firstSqlToken('')).toBe('other');
|
||||
expect(firstSqlToken(undefined)).toBe('other');
|
||||
expect(firstSqlToken('123 not sql')).toBe('other');
|
||||
expect(firstSqlToken('vacuum analyze')).toBe('other');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AI_CHAT_THROTTLER,
|
||||
PAGE_TEMPLATE_THROTTLER,
|
||||
PUBLIC_SHARE_AI_THROTTLER,
|
||||
VITALS_THROTTLER,
|
||||
} from './throttler-names';
|
||||
|
||||
@Module({
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
{ 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
|
||||
|
||||
@@ -6,3 +6,7 @@ export const PAGE_TEMPLATE_THROTTLER = 'page-template';
|
||||
// ThrottlerGuard tracker) to bound anonymous abuse — the workspace owner pays
|
||||
// for the tokens.
|
||||
export const PUBLIC_SHARE_AI_THROTTLER = 'public-share-ai';
|
||||
// IP-keyed throttler for the anonymous client perf-telemetry sink
|
||||
// (POST /api/telemetry/vitals). Browsers batch metrics, so the limit is
|
||||
// generous; it only exists to bound abuse of the public, unauthenticated route.
|
||||
export const VITALS_THROTTLER = 'vitals';
|
||||
|
||||
Reference in New Issue
Block a user