fix(#355 review E1=B + F1-F8): gate client telemetry OFF by default + throttler/lifecycle/overflow fixes
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>
This commit is contained in:
@@ -227,6 +227,22 @@ export class EnvironmentService {
|
||||
return compactTree === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator toggle for the public client-telemetry sink (#355). DEFAULT OFF:
|
||||
* the unauthenticated POST /api/telemetry/vitals endpoint + client vitals
|
||||
* collection are only wired when this is explicitly true. Kept SEPARATE from
|
||||
* METRICS_PORT (the server Prometheus half) because Grafana reads the
|
||||
* `client_metrics` table directly, independent of the scrape port — and
|
||||
* because `client_metrics` has no app-side retention, so an operator must opt
|
||||
* in and run an external pruner.
|
||||
*/
|
||||
isClientTelemetryEnabled(): boolean {
|
||||
const enabled = this.configService
|
||||
.get<string>('CLIENT_TELEMETRY_ENABLED', 'false')
|
||||
.toLowerCase();
|
||||
return enabled === 'true';
|
||||
}
|
||||
|
||||
getStripePublishableKey(): string {
|
||||
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { closeMetricsServer } from './metrics.server';
|
||||
|
||||
/**
|
||||
* Ties the bare node:http metrics scrape server (started in main.ts after the
|
||||
* Fastify app is up, outside the DI container) into Nest's shutdown lifecycle.
|
||||
* With `app.enableShutdownHooks()`, onModuleDestroy fires on SIGTERM/SIGINT and
|
||||
* closes the listener so it is not left dangling (jest/e2e never exits, and a
|
||||
* prod restart doesn't leak the port). No-op when metrics are disabled.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MetricsServerLifecycle implements OnModuleDestroy {
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await closeMetricsServer();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MetricsBullService } from './metrics-bull.service';
|
||||
import { MetricsServerLifecycle } from './metrics-server.lifecycle';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* MetricsServerLifecycle closes the scrape server on shutdown.
|
||||
*/
|
||||
@Module({
|
||||
providers: [MetricsBullService],
|
||||
providers: [MetricsBullService, MetricsServerLifecycle],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
|
||||
@@ -3,13 +3,19 @@ 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.
|
||||
* Start the Prometheus scrape endpoint on a SEPARATE port, taken from
|
||||
* `METRICS_PORT`. There is NO default port: when `METRICS_PORT` is unset the
|
||||
* whole metrics subsystem is OFF and this returns null. 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.
|
||||
* metrics are disabled. The reference is also kept module-side so the Nest
|
||||
* lifecycle (see MetricsModule) can close it on application shutdown without
|
||||
* threading the handle back through the non-DI bootstrap.
|
||||
*/
|
||||
let metricsServer: Server | null = null;
|
||||
|
||||
export function startMetricsServer(): Server | null {
|
||||
if (!isMetricsEnabled()) return null;
|
||||
|
||||
@@ -52,5 +58,20 @@ export function startMetricsServer(): Server | null {
|
||||
logger.error(`Metrics server error: ${err?.message}`);
|
||||
});
|
||||
|
||||
metricsServer = server;
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the metrics scrape server if one is running. Idempotent and safe to call
|
||||
* when metrics are disabled (no server was ever started). Wired into Nest's
|
||||
* shutdown lifecycle so the listener is not left dangling on shutdown.
|
||||
*/
|
||||
export function closeMetricsServer(): Promise<void> {
|
||||
const server = metricsServer;
|
||||
metricsServer = null;
|
||||
if (!server) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ export class StaticModule implements OnModuleInit {
|
||||
: undefined,
|
||||
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
||||
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
||||
// #355 — mirrors the server-side CLIENT_TELEMETRY_ENABLED gate so the
|
||||
// client only collects/sends vitals when the operator opts in.
|
||||
CLIENT_TELEMETRY_ENABLED:
|
||||
this.environmentService.isClientTelemetryEnabled(),
|
||||
};
|
||||
|
||||
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
||||
|
||||
Reference in New Issue
Block a user