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>
150 lines
4.6 KiB
TypeScript
150 lines
4.6 KiB
TypeScript
import { VitalsService } from './vitals.service';
|
|
import { MAX_ATTR_LENGTH } from './client-metrics.constants';
|
|
|
|
// buildRows is pure (no DB access), so a null db is fine here.
|
|
const svc = new VitalsService(null as any);
|
|
|
|
describe('VitalsService.buildRows', () => {
|
|
const WS = 'ws-uuid';
|
|
|
|
it('accepts a valid batch and maps whitelisted fields to rows', () => {
|
|
const body = {
|
|
events: [
|
|
{ name: 'INP', value: 123.4, rating: 'good', route: '/s/:space/p/:slug' },
|
|
{ name: 'editor_tx_ms', value: 12, route: '/s/:space/p/:slug', docSize: 4096 },
|
|
],
|
|
};
|
|
const rows = svc.buildRows(body, WS);
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows[0]).toEqual({
|
|
name: 'INP',
|
|
value: 123.4,
|
|
rating: 'good',
|
|
route: '/s/:space/p/:slug',
|
|
attr: null,
|
|
docSize: null,
|
|
workspaceId: WS,
|
|
});
|
|
expect(rows[1].name).toBe('editor_tx_ms');
|
|
expect(rows[1].docSize).toBe(4096);
|
|
expect(rows[1].workspaceId).toBe(WS);
|
|
});
|
|
|
|
it('accepts a bare array body', () => {
|
|
const rows = svc.buildRows([{ name: 'LCP', value: 1 }], WS);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].name).toBe('LCP');
|
|
});
|
|
|
|
it('drops events with foreign metric names', () => {
|
|
const rows = svc.buildRows(
|
|
{ events: [{ name: 'evil_metric', value: 1 }, { name: 'LCP', value: 2 }] },
|
|
WS,
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].name).toBe('LCP');
|
|
});
|
|
|
|
it('drops events with a non-numeric or missing value', () => {
|
|
const rows = svc.buildRows(
|
|
{
|
|
events: [
|
|
{ name: 'CLS', value: 'nan' },
|
|
{ name: 'CLS' },
|
|
{ name: 'CLS', value: 0.1 },
|
|
],
|
|
},
|
|
WS,
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].value).toBe(0.1);
|
|
});
|
|
|
|
it('strips foreign fields and only keeps whitelisted columns', () => {
|
|
const rows = svc.buildRows(
|
|
{ events: [{ name: 'TTFB', value: 5, secret: 'drop-me', title: 'my page' }] },
|
|
WS,
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
expect(Object.keys(rows[0]).sort()).toEqual(
|
|
['attr', 'docSize', 'name', 'rating', 'route', 'value', 'workspaceId'].sort(),
|
|
);
|
|
expect((rows[0] as any).secret).toBeUndefined();
|
|
expect((rows[0] as any).title).toBeUndefined();
|
|
});
|
|
|
|
it('rejects a rating outside the allowed set (-> null)', () => {
|
|
const rows = svc.buildRows(
|
|
{ events: [{ name: 'INP', value: 1, rating: 'terrible' }] },
|
|
WS,
|
|
);
|
|
expect(rows[0].rating).toBeNull();
|
|
});
|
|
|
|
it('truncates attr to 120 chars', () => {
|
|
const longAttr = 'a'.repeat(500);
|
|
const rows = svc.buildRows(
|
|
{ events: [{ name: 'INP', value: 1, attr: longAttr }] },
|
|
WS,
|
|
);
|
|
expect(rows[0].attr).toHaveLength(MAX_ATTR_LENGTH);
|
|
});
|
|
|
|
it('caps the batch at 50 events', () => {
|
|
const events = Array.from({ length: 200 }, () => ({ name: 'CLS', value: 1 }));
|
|
const rows = svc.buildRows({ events }, WS);
|
|
expect(rows).toHaveLength(50);
|
|
});
|
|
|
|
it('drops an oversized (>16KB) payload wholesale', () => {
|
|
const events = Array.from({ length: 50 }, () => ({
|
|
name: 'INP',
|
|
value: 1,
|
|
attr: 'x'.repeat(400),
|
|
route: '/s/:space/p/:slug',
|
|
}));
|
|
// Serialised body far exceeds 16KB.
|
|
const rows = svc.buildRows({ events }, WS);
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it('returns [] for malformed bodies', () => {
|
|
expect(svc.buildRows(null, WS)).toEqual([]);
|
|
expect(svc.buildRows('nope', WS)).toEqual([]);
|
|
expect(svc.buildRows({ notEvents: 1 }, WS)).toEqual([]);
|
|
expect(svc.buildRows(42, WS)).toEqual([]);
|
|
});
|
|
|
|
it('carries a null workspaceId through', () => {
|
|
const rows = svc.buildRows({ events: [{ name: 'LCP', value: 1 }] }, null);
|
|
expect(rows[0].workspaceId).toBeNull();
|
|
});
|
|
|
|
it('drops an out-of-int4-range docSize to null without losing the batch', () => {
|
|
const rows = svc.buildRows(
|
|
{
|
|
events: [
|
|
// Garbage docSize overflowing int4 must NOT reject the whole batch:
|
|
// the field is dropped to null and the event is kept.
|
|
{ name: 'editor_tx_ms', value: 10, docSize: 9_999_999_999 },
|
|
{ name: 'editor_tx_ms', value: 20, docSize: -5 },
|
|
{ name: 'editor_tx_ms', value: 30, docSize: 4096 },
|
|
],
|
|
},
|
|
WS,
|
|
);
|
|
expect(rows).toHaveLength(3);
|
|
expect(rows[0].docSize).toBeNull();
|
|
expect(rows[1].docSize).toBeNull();
|
|
expect(rows[2].docSize).toBe(4096);
|
|
});
|
|
|
|
it('keeps a docSize exactly at the int4 max', () => {
|
|
const rows = svc.buildRows(
|
|
{ events: [{ name: 'editor_tx_ms', value: 1, docSize: 2147483647 }] },
|
|
WS,
|
|
);
|
|
expect(rows[0].docSize).toBe(2147483647);
|
|
});
|
|
});
|