d3209b5aab
Maintainer resolved E1 as variant B: the public vitals sink + client collection must be OFF by default (else client_metrics grows unbounded on a self-host deploy with no external pruner, via an unauthenticated public endpoint). - F1: new operator flag CLIENT_TELEMETRY_ENABLED (default OFF), SEPARATE from METRICS_PORT (Grafana reads the table directly, independent of the scrape port). ClientTelemetryModule.register() provides VitalsController ONLY when the flag is true (route absent otherwise); the flag reaches the client via window.CONFIG (config.ts isClientTelemetryEnabled), and initVitals() early-returns when off. - F2/F3 [throttler]: this repo's ThrottlerGuard applies EVERY named throttler to every guarded route unless skipped. The new VITALS bucket therefore (a) newly bound collab-token → 429 behind shared/NAT IPs, and (b) the vitals route didn't skip the stricter public-share-ai (5/min) bucket → effective 5/min not 120. Fix (additive, global config unchanged): vitals.controller @SkipThrottle the other buckets + @Throttle VITALS 120/min; collab-token adds VITALS_THROTTLER to its existing @SkipThrottle (restoring its prior effectively-unthrottled state). - F4: metrics node:http server is closed on shutdown (MetricsServerLifecycle OnModuleDestroy → closeMetricsServer(), fired by enableShutdownHooks). - F5: docSize outside [0, int4-max] drops to null (keeping the event) instead of overflowing int4 and failing the WHOLE batch insert (+ 2 tests). - F6: .env.example documents METRICS_PORT (no default — unset = subsystem OFF) + CLIENT_TELEMETRY_ENABLED; fixed the inaccurate "default 9464" wording. - F7: disabled/non-sampled sessions install ZERO observers — isVitalsActive() (enabled && sampled) gates reportClientMetric AND the page-editor measurePageOpen + dispatchTransaction wrapping. - F8: kept db.d.ts hand-added (wontfix) — this repo HAND-CURATES db.d.ts (verified across recent fork migrations a32fba63/8c5b57eb/fdeede00); codegen would be the deviation. The ClientMetrics interface maps the migration 1:1. Gate: server tsc 0, client tsc 0, server metrics/vitals/telemetry/throttle 21 tests, client route-template 5. No new deps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
65 lines
2.1 KiB
TypeScript
65 lines
2.1 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
HttpCode,
|
|
Post,
|
|
Req,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { SkipThrottle, Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
|
import { FastifyRequest } from 'fastify';
|
|
import { Public } from '../../common/decorators/public.decorator';
|
|
import {
|
|
AI_CHAT_THROTTLER,
|
|
AUTH_THROTTLER,
|
|
PAGE_TEMPLATE_THROTTLER,
|
|
PUBLIC_SHARE_AI_THROTTLER,
|
|
VITALS_THROTTLER,
|
|
} from '../../integrations/throttle/throttler-names';
|
|
import { VitalsService } from './vitals.service';
|
|
|
|
/**
|
|
* POST /api/telemetry/vitals (#355) — public client perf-metrics sink.
|
|
*
|
|
* PUBLIC (browsers post via sendBeacon, no session) but IP-throttled. Always
|
|
* returns 200 with no body of interest: invalid/foreign/oversized payloads are
|
|
* silently dropped by the service rather than 400'd, so browsers never retry.
|
|
*/
|
|
@Controller('telemetry')
|
|
export class VitalsController {
|
|
constructor(private readonly vitalsService: VitalsService) {}
|
|
|
|
@Public()
|
|
@UseGuards(ThrottlerGuard)
|
|
// The global ThrottlerGuard applies ALL named throttlers to every route, so
|
|
// every OTHER bucket must be skipped here — otherwise the strictest of them
|
|
// (public-share AI at 5/min) would override the intended vitals limit and cap
|
|
// this route at 5/min instead of 120/min. Skip them all so ONLY the VITALS
|
|
// bucket below applies.
|
|
@SkipThrottle({
|
|
[AUTH_THROTTLER]: true,
|
|
[AI_CHAT_THROTTLER]: true,
|
|
[PAGE_TEMPLATE_THROTTLER]: true,
|
|
[PUBLIC_SHARE_AI_THROTTLER]: true,
|
|
})
|
|
@Throttle({ [VITALS_THROTTLER]: { limit: 120, ttl: 60_000 } })
|
|
@Post('vitals')
|
|
@HttpCode(200)
|
|
async vitals(
|
|
@Body() body: unknown,
|
|
@Req() req: FastifyRequest,
|
|
): Promise<{ ok: true }> {
|
|
// workspaceId is resolved by the workspace-host middleware onto req.raw when
|
|
// the browser posts from a workspace host; null otherwise. No other PII.
|
|
const workspaceId =
|
|
((req.raw as unknown as { workspaceId?: string })?.workspaceId ?? null) ||
|
|
null;
|
|
try {
|
|
await this.vitalsService.ingest(body, workspaceId);
|
|
} catch {
|
|
// Never surface storage errors to the browser; telemetry is best-effort.
|
|
}
|
|
return { ok: true };
|
|
}
|
|
}
|