Files
gitmost/apps/server/src/integrations/metrics/metrics.spec.ts
T
agent_coder 96db9b6c7f feat(metrics): #402 pass 1 — collab-cycle observability (load/lifecycle/connect/auth)
Extends the #355 perf-metrics registry (all behind the METRICS_PORT hard
gate — nullable instruments, no-op helpers when unset). New families:

- collab_doc_load_duration_seconds{size_bucket} — onLoadDocument timed on
  the real DB-load paths only (the already-loaded early-return is skipped).
- size_bucket added to collab_store_duration_seconds; storeDocument returns
  the ydoc byte length (reusing its single Y.encodeStateAsUpdate, no second
  encode) so onStoreDocument observes size without extra cost.
- collab_docs_open (gauge, read-on-scrape via collect() from
  hocuspocus.getDocumentsCount() — never inc/dec'd, so it can't drift) +
  collab_doc_loads_total / collab_doc_unloads_total (afterLoad/afterUnload).
- collab_connect_duration_seconds — onConnect->connected, correlated by a
  WeakMap keyed on the shared request (leak-free; the current client uses
  one socket per document).
- collab_auth_duration_seconds — wraps onAuthenticate (success and failure).
- sizeBucket() shared helper (lt64k|lt256k|lt1m|ge1m, 4 bounded values).

The mcp_tool_duration_seconds histogram + collab_connect_timeouts_total
counter helpers are registered here but wired in pass 2 (MCP callback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 02:10:54 +03:00

207 lines
7.3 KiB
TypeScript

import { FastifyRequest } from 'fastify';
import { resolveRouteLabel } from './http-metrics.hook';
import {
firstSqlToken,
isStreamingResponse,
sizeBucket,
} from './metrics.constants';
import {
getMetricsRegistry,
incConnectTimeout,
incDocLoad,
incDocUnload,
isMetricsEnabled,
observeCollabAuth,
observeCollabConnect,
observeCollabLoad,
observeCollabStore,
observeMcpTool,
registerDocsOpenSource,
} from './metrics.registry';
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');
});
it.each([
'/assets/index-CAbxDtto.js',
'/assets/chunk-3OPIFGDE-CJOt9nr5.js',
'/assets/excalidraw-menu-DpsI0kFW.js',
'/vad/silero_vad_v5.onnx',
'/brand/logo.svg',
'/locales/en.json',
'/icons/app-icon-192x192.png',
])('collapses hashed/static asset %p to "static" (#362 cardinality)', (url) => {
// @fastify/static serves each file through a route whose matched url is the
// raw (hashed) file path, so routeOptions.url is itself unbounded here.
const req = {
url,
routeOptions: { url },
} as unknown as FastifyRequest;
const label = resolveRouteLabel(req);
expect(label).toBe('static');
expect(label).not.toContain('.js');
expect(label).not.toContain('index-');
});
it('strips the query string before the static-prefix check', () => {
const req = {
url: '/assets/index-CAbxDtto.js?v=2',
routeOptions: { url: '/assets/index-CAbxDtto.js' },
} as unknown as FastifyRequest;
expect(resolveRouteLabel(req)).toBe('static');
});
it('does NOT collapse a real API route that merely mentions assets', () => {
// A templated API route is kept as-is; only the static path PREFIXES collapse.
const req = {
url: '/api/pages/assets-guide',
routeOptions: { url: '/api/pages/:id' },
} as unknown as FastifyRequest;
expect(resolveRouteLabel(req)).toBe('/api/pages/:id');
});
it.each([
// The TRAILING SLASH on the prefix is the anti-false-collapse guard: a path
// that is the prefix WITHOUT its slash, or merely shares the prefix as a
// substring of a longer segment, must NOT collapse. These would collapse
// under a buggy `includes('/assets/')` / slashless-prefix impl.
'/assets',
'/assetsx/foo.js',
'/iconset/x.png',
])('does NOT collapse the prefix-boundary case %p', (url) => {
const req = {
url,
routeOptions: { url: '/some/:route' },
} as unknown as FastifyRequest;
expect(resolveRouteLabel(req)).not.toBe('static');
expect(resolveRouteLabel(req)).toBe('/some/:route');
});
});
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');
});
});
describe('sizeBucket (#402 bounded size label)', () => {
it('maps sizes to the four fixed buckets at their boundaries', () => {
// Boundaries are exclusive-upper: <65536 → lt64k, etc.
expect(sizeBucket(0)).toBe('lt64k');
expect(sizeBucket(65535)).toBe('lt64k');
expect(sizeBucket(65536)).toBe('lt256k');
expect(sizeBucket(262143)).toBe('lt256k');
expect(sizeBucket(262144)).toBe('lt1m');
expect(sizeBucket(1048575)).toBe('lt1m');
expect(sizeBucket(1048576)).toBe('ge1m');
expect(sizeBucket(5_000_000)).toBe('ge1m');
});
it('falls back to the smallest bucket for invalid sizes', () => {
// Non-finite / negative / nullish collapse to the smallest, safe default.
expect(sizeBucket(-1)).toBe('lt64k');
expect(sizeBucket(NaN)).toBe('lt64k');
expect(sizeBucket(Infinity)).toBe('lt64k');
expect(sizeBucket(undefined)).toBe('lt64k');
expect(sizeBucket(null)).toBe('lt64k');
});
it('only ever returns one of the four fixed labels (bounded cardinality)', () => {
const labels = new Set(
[0, 65536, 262144, 1048576, -5, NaN].map((b) => sizeBucket(b)),
);
for (const l of labels) {
expect(['lt64k', 'lt256k', 'lt1m', 'ge1m']).toContain(l);
}
});
});
describe('metrics helpers are safe no-ops when METRICS_PORT is unset', () => {
// These specs run without METRICS_PORT, so the registry is never created and
// every observe/inc/set helper must be a cheap `?.` no-op that never throws.
beforeAll(() => {
// Guard the contract this suite depends on: if a CI env set METRICS_PORT,
// the assertions below would be meaningless, so fail loudly instead.
expect(process.env.METRICS_PORT).toBeUndefined();
});
it('reports metrics disabled and a null registry', () => {
expect(isMetricsEnabled()).toBe(false);
expect(getMetricsRegistry()).toBeNull();
});
it('does not throw from any #402 collab/MCP helper', () => {
expect(() => {
observeCollabLoad(123456, 0.01);
observeCollabStore(123456, 0.02);
observeCollabConnect(0.03);
observeCollabAuth(0.04);
observeMcpTool('some-tool', 0.05);
incDocLoad();
incDocUnload();
incConnectTimeout();
// Registering a source must not create the gauge or invoke the fn.
registerDocsOpenSource(() => {
throw new Error('docsOpenSource must NOT be called when disabled');
});
}).not.toThrow();
});
});