feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Registry } from 'prom-client';
import { createGatewayMetrics } from './gateway-metrics.js';
describe('metrics/gateway-metrics', () => {
let reg: Registry;
beforeEach(() => {
reg = new Registry();
});
it('registers all 11 metrics under the supplied prefix', async () => {
createGatewayMetrics(reg, 'aao_gateway_t');
const out = await reg.metrics();
for (const name of [
'aao_gateway_t_requests_total',
'aao_gateway_t_request_duration_seconds',
'aao_gateway_t_tokens_total',
'aao_gateway_t_backend_busy_slots',
'aao_gateway_t_backend_total_slots',
'aao_gateway_t_backend_online',
'aao_gateway_t_cache_hit_total',
'aao_gateway_t_cache_miss_total',
'aao_gateway_t_virtual_key_budget_used_ratio',
'aao_gateway_t_rate_limit_rejections_total',
'aao_gateway_t_active_streams',
]) {
expect(out).toContain(name);
}
});
it('counters emit a series per unique label set', async () => {
const m = createGatewayMetrics(reg, 'aao_gateway_c');
m.requestsTotal.inc({ team: 'alpha', backend: 'gpu-a', model: 'qwen3:8b', status: 'success' }, 3);
m.requestsTotal.inc({ team: 'alpha', backend: 'gpu-a', model: 'qwen3:8b', status: 'success' }, 2);
m.requestsTotal.inc({ team: 'bravo', backend: 'gpu-b', model: 'qwen3:8b', status: 'success' }, 1);
const out = await reg.metrics();
// 3 + 2 = 5 on the alpha series
expect(out).toMatch(/aao_gateway_c_requests_total\{[^}]*team="alpha"[^}]*\} 5/);
expect(out).toMatch(/aao_gateway_c_requests_total\{[^}]*team="bravo"[^}]*\} 1/);
});
it('histogram observe records a single sample', async () => {
const m = createGatewayMetrics(reg, 'aao_gateway_h');
m.requestDurationSeconds.observe(
{ team: 'alpha', backend: 'gpu-a', model: 'qwen3:8b', status: 'success' },
0.42,
);
const out = await reg.metrics();
expect(out).toContain('aao_gateway_h_request_duration_seconds_count{');
expect(out).toMatch(/aao_gateway_h_request_duration_seconds_sum\{[^}]+\} 0\.42/);
});
it('gauge set + reset returns latest value', async () => {
const m = createGatewayMetrics(reg, 'aao_gateway_g');
m.backendBusySlots.set({ backend: 'gpu-a' }, 4);
m.backendBusySlots.set({ backend: 'gpu-a' }, 7);
const out = await reg.metrics();
expect(out).toMatch(/aao_gateway_g_backend_busy_slots\{backend="gpu-a"\} 7/);
});
it('activeStreams gauge has no labels', async () => {
const m = createGatewayMetrics(reg, 'aao_gateway_s');
m.activeStreams.set(5);
const out = await reg.metrics();
expect(out).toContain('aao_gateway_s_active_streams 5');
});
it('resetMetrics zeroes counters without unregistering', async () => {
const m = createGatewayMetrics(reg, 'aao_gateway_r');
m.cacheHitTotal.inc({ cache: 'key' }, 10);
reg.resetMetrics();
const out = await reg.metrics();
expect(out).toContain('aao_gateway_r_cache_hit_total');
// No data line after reset (counter not yet observed post-reset)
expect(out).not.toMatch(/aao_gateway_r_cache_hit_total\{cache="key"\} 10/);
});
it('is idempotent on (registry, prefix) — returns the cached handle, does NOT throw', () => {
// CRITICAL-1 fix: prom-client throws on duplicate metric name
// registration, but createGatewayMetrics is memoized so callers
// (e.g. same-process gateway bounces with a shared bridge
// Registry) can safely call it again. The cache is per (registry,
// prefix) so a different prefix on the same registry still
// registers fresh counters.
const first = createGatewayMetrics(reg, 'aao_gateway_dup');
expect(() => createGatewayMetrics(reg, 'aao_gateway_dup')).not.toThrow();
const second = createGatewayMetrics(reg, 'aao_gateway_dup');
expect(second).toBe(first); // same handle, no fresh Counter instances
// Different prefix → fresh registration, no cache hit
const other = createGatewayMetrics(reg, 'aao_gateway_other');
expect(other).not.toBe(first);
});
});
+223
View File
@@ -0,0 +1,223 @@
/**
* Phase 3b — Gateway-side Prometheus metrics.
*
* 11 metrics, all prefixed with `aao_gateway_` by default. Labels are
* kept narrow to bound cardinality: `team` and `backend` come from the
* static config (small N), `model` from a request body (bounded by the
* router's allowlist), `status` from a closed enum.
*
* Hot-path emission order:
* - `requestsTotal` + `requestDurationSeconds`: fired in
* stream-proxy.ts's finally for every response (success and error).
* - `tokensTotal`: fired alongside requestsTotal when usage was
* observed (skipped when zero — we don't want labels with 0 sum).
* - `backendBusySlots` / `backendTotalSlots` / `backendOnline`: pushed
* from a BackendStatusRegistry.subscribe() callback in bootstrap.
* - `cacheHitTotal` / `cacheMissTotal`: bootstrap's dbLookup wrapper
* inc()s these on each path through the keyCache.
* - `budgetUsedRatio`: pushed when a usage write completes
* (recordUsage callback in bootstrap).
* - `rateLimitRejectionsTotal`: fired by the rate-limit middleware
* reject branch.
* - `activeStreams`: bootstrap subscribes a tick that reads the
* StreamRegistry size every 5s (gauge — not delta).
*
* Cardinality budget (5 backends × 3 teams × 5 models × 5 statuses):
* - requestsTotal: 375 series — fine
* - requestDurationSeconds: same × Prom default 10 buckets = 3750
* observation series — still well under the 100k informal cap
*/
import { Counter, Gauge, Histogram, type Registry } from 'prom-client';
export interface GatewayMetrics {
/** Total chat/completions requests fired. Labels: team, backend, model, status. */
requestsTotal: Counter<'team' | 'backend' | 'model' | 'status'>;
/** Request processing latency seconds (default buckets). */
requestDurationSeconds: Histogram<'team' | 'backend' | 'model' | 'status'>;
/** Cumulative tokens routed through the gateway. direction is in/out. */
tokensTotal: Counter<'team' | 'backend' | 'model' | 'direction'>;
/** Latest BackendStatusRegistry busySlots for each backend. */
backendBusySlots: Gauge<'backend'>;
/** Latest totalSlots for each backend (constant per backend, but expose for completeness). */
backendTotalSlots: Gauge<'backend'>;
/** 1 = online, 0 = offline (registry probe failed). */
backendOnline: Gauge<'backend'>;
/** keyCache hit counter. Label `cache` keeps room for budget / rate / key sub-caches. */
cacheHitTotal: Counter<'cache'>;
/** keyCache miss counter. */
cacheMissTotal: Counter<'cache'>;
/**
* Per-key budget usage ratio (used / budget) in [0, 1+]. Reset monthly
* when the underlying gateway_key_usage period rolls over.
*/
budgetUsedRatio: Gauge<'team' | 'key_prefix'>;
/** 429s emitted by the rate-limit middleware. */
rateLimitRejectionsTotal: Counter<'team'>;
/** Currently in-flight SSE streams (StreamRegistry size). */
activeStreams: Gauge;
}
/**
* Per-(registry, prefix) memoization cache.
*
* prom-client throws on duplicate metric name registration. In
* same-process gateway mode the bridge owns one shared Registry but
* `createGatewayMetrics` is invoked again every time `startGateway()`
* runs (e.g. `enabled: false → true → false → true` bounce, or a
* backend list edit triggering a stop+start cycle). Without
* memoization the second start throws and crashes the bridge process.
*
* Cache key: the Registry instance × the metric-name prefix. Using a
* WeakMap on the Registry lets the cache GC naturally when the
* registry is dropped (tests typically discard registries between
* cases). The inner `Map<prefix, GatewayMetrics>` allows multiple
* prefixes against the same registry (rare but legitimate — e.g. two
* gateway mounts on different prefixes in test).
*
* Stop()/teardown semantics: callers MUST NOT delete metric instances
* from the registry on stop — doing so would break the next start
* (the cache would still hold a Counter whose internal registry
* pointer dangles). Stop should reset label values via `.remove(label)`
* or `.reset()` only.
*/
const metricsCache = new WeakMap<Registry, Map<string, GatewayMetrics>>();
/**
* Wire all 11 metrics onto the supplied registry and return the typed
* handle. Pass the same registry instance to every call site so a
* `/metrics` scrape sees a single coherent snapshot.
*
* `prefix` only changes the metric name root; we keep it as a function
* arg so tests can use a unique prefix per case and avoid the
* "duplicate registration" prom-client error.
*
* Idempotent on `(registry, prefix)` — a second call with the same
* args returns the cached handle instead of re-registering counters
* (which prom-client would reject by throwing). See `metricsCache`
* doc for why same-process gateway bounces need this.
*/
export function createGatewayMetrics(
registry: Registry,
prefix: string = 'aao_gateway',
): GatewayMetrics {
let perRegistry = metricsCache.get(registry);
if (perRegistry) {
const cached = perRegistry.get(prefix);
if (cached) return cached;
} else {
perRegistry = new Map<string, GatewayMetrics>();
metricsCache.set(registry, perRegistry);
}
const handle = buildGatewayMetrics(registry, prefix);
perRegistry.set(prefix, handle);
return handle;
}
function buildGatewayMetrics(registry: Registry, prefix: string): GatewayMetrics {
const requestsTotal = new Counter({
name: `${prefix}_requests_total`,
help: 'Total chat/completions requests processed by the gateway.',
labelNames: ['team', 'backend', 'model', 'status'] as const,
registers: [registry],
});
const requestDurationSeconds = new Histogram({
name: `${prefix}_request_duration_seconds`,
help: 'Gateway request processing time in seconds (incl. upstream).',
labelNames: ['team', 'backend', 'model', 'status'] as const,
// Prom-client default buckets are tuned for HTTP latency.
registers: [registry],
});
const tokensTotal = new Counter({
name: `${prefix}_tokens_total`,
help: 'Cumulative tokens routed through the gateway (in / out).',
labelNames: ['team', 'backend', 'model', 'direction'] as const,
registers: [registry],
});
const backendBusySlots = new Gauge({
name: `${prefix}_backend_busy_slots`,
help: 'BackendStatusRegistry busySlots for each backend.',
labelNames: ['backend'] as const,
registers: [registry],
});
const backendTotalSlots = new Gauge({
name: `${prefix}_backend_total_slots`,
help: 'BackendStatusRegistry totalSlots for each backend.',
labelNames: ['backend'] as const,
registers: [registry],
});
const backendOnline = new Gauge({
name: `${prefix}_backend_online`,
help: 'Backend health: 1 = reachable, 0 = probe failed.',
labelNames: ['backend'] as const,
registers: [registry],
});
const cacheHitTotal = new Counter({
name: `${prefix}_cache_hit_total`,
help: 'Key cache hits by sub-cache (key/backends).',
labelNames: ['cache'] as const,
registers: [registry],
});
const cacheMissTotal = new Counter({
name: `${prefix}_cache_miss_total`,
help: 'Key cache misses by sub-cache.',
labelNames: ['cache'] as const,
registers: [registry],
});
const budgetUsedRatio = new Gauge({
name: `${prefix}_virtual_key_budget_used_ratio`,
help: 'Per-virtual-key budget usage ratio (used / budget); 0 means unlimited.',
labelNames: ['team', 'key_prefix'] as const,
registers: [registry],
});
const rateLimitRejectionsTotal = new Counter({
name: `${prefix}_rate_limit_rejections_total`,
help: 'Total 429 rejections by the rate-limit middleware.',
labelNames: ['team'] as const,
registers: [registry],
});
const activeStreams = new Gauge({
name: `${prefix}_active_streams`,
help: 'In-flight SSE streams managed by the StreamRegistry.',
registers: [registry],
});
return {
requestsTotal,
requestDurationSeconds,
tokensTotal,
backendBusySlots,
backendTotalSlots,
backendOnline,
cacheHitTotal,
cacheMissTotal,
budgetUsedRatio,
rateLimitRejectionsTotal,
activeStreams,
};
}
/**
* Closed enum of request status labels — exported so call sites get a
* compile-time check rather than scattering string literals.
*/
export type GatewayRequestStatus =
| 'success'
| 'upstream_error'
| 'gateway_timeout'
| 'gateway_shutdown'
| 'client_aborted'
| 'auth_fail'
| 'budget_exhausted'
| 'rate_limited'
| 'no_backend';
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Registry } from 'prom-client';
import { createMetricsHandler } from './http-handler.js';
import { createGatewayMetrics } from './gateway-metrics.js';
describe('metrics/http-handler', () => {
function makeApp(): { app: express.Express; reg: Registry } {
const app = express();
const reg = new Registry();
createGatewayMetrics(reg, 'aao_gateway_h');
// Default (no auth opts) → localhost-only allowlist; supertest
// connects from 127.0.0.1 so it passes.
app.get('/metrics', createMetricsHandler(reg));
return { app, reg };
}
it('responds 200 with text/plain version=0.0.4', async () => {
const { app } = makeApp();
const res = await request(app).get('/metrics');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/^text\/plain.*version=0\.0\.4/);
});
it('payload contains registered metric names', async () => {
const { app } = makeApp();
const res = await request(app).get('/metrics');
expect(res.text).toContain('aao_gateway_h_requests_total');
});
it('returns 500 with text/plain when render throws', async () => {
const reg = {
contentType: 'text/plain',
// Force a rejection out of metrics()
metrics: () => Promise.reject(new Error('boom')),
} as unknown as Registry;
const app = express();
app.get('/metrics', createMetricsHandler(reg));
const res = await request(app).get('/metrics');
expect(res.status).toBe(500);
expect(res.headers['content-type']).toMatch(/text\/plain/);
});
describe('auth gating (Phase 3b post-review)', () => {
it('default config allows 127.0.0.1 (localhost)', async () => {
const reg = new Registry();
createGatewayMetrics(reg, 'aao_gateway_l1');
const app = express();
app.get('/metrics', createMetricsHandler(reg));
const res = await request(app).get('/metrics');
expect(res.status).toBe(200);
});
it('default config rejects a non-localhost client IP with 403', async () => {
const reg = new Registry();
createGatewayMetrics(reg, 'aao_gateway_l2');
const app = express();
// Simulate an external IP via a stub middleware before the
// handler. Defining `req.ip` after Express assigned it requires
// setting the underlying socket; we override the getter directly.
app.use((req, _res, next) => {
Object.defineProperty(req, 'ip', { value: '203.0.113.5', configurable: true });
// Also override socket.remoteAddress so the fallback check
// doesn't see the real supertest socket (127.0.0.1).
Object.defineProperty(req, 'socket', {
value: { remoteAddress: '203.0.113.5' },
configurable: true,
});
next();
});
app.get('/metrics', createMetricsHandler(reg));
const res = await request(app).get('/metrics');
expect(res.status).toBe(403);
});
it('bearer token: 200 on correct Authorization header', async () => {
const reg = new Registry();
createGatewayMetrics(reg, 'aao_gateway_t1');
const app = express();
app.get('/metrics', createMetricsHandler(reg, { bearerToken: 'sk-metrics-secret' }));
const res = await request(app).get('/metrics').set('Authorization', 'Bearer sk-metrics-secret');
expect(res.status).toBe(200);
});
it('bearer token: 401 with WWW-Authenticate when missing/wrong', async () => {
const reg = new Registry();
createGatewayMetrics(reg, 'aao_gateway_t2');
const app = express();
app.get('/metrics', createMetricsHandler(reg, { bearerToken: 'sk-metrics-secret' }));
const r1 = await request(app).get('/metrics');
expect(r1.status).toBe(401);
expect(r1.headers['www-authenticate']).toMatch(/Bearer/);
const r2 = await request(app).get('/metrics').set('Authorization', 'Bearer wrong-token');
expect(r2.status).toBe(401);
});
it('bearer token wins over IP allowlist (correct token from external IP → 200)', async () => {
const reg = new Registry();
createGatewayMetrics(reg, 'aao_gateway_t3');
const app = express();
app.use((req, _res, next) => {
Object.defineProperty(req, 'ip', { value: '203.0.113.5', configurable: true });
// Also override socket.remoteAddress so the fallback check
// doesn't see the real supertest socket (127.0.0.1).
Object.defineProperty(req, 'socket', {
value: { remoteAddress: '203.0.113.5' },
configurable: true,
});
next();
});
app.get('/metrics', createMetricsHandler(reg, {
bearerToken: 'tok',
allowedHosts: ['127.0.0.1'],
}));
const res = await request(app).get('/metrics').set('Authorization', 'Bearer tok');
expect(res.status).toBe(200);
});
it('allowedHosts containing 0.0.0.0 disables IP checks', async () => {
const reg = new Registry();
createGatewayMetrics(reg, 'aao_gateway_h0');
const app = express();
app.use((req, _res, next) => {
Object.defineProperty(req, 'ip', { value: '198.51.100.7', configurable: true });
Object.defineProperty(req, 'socket', {
value: { remoteAddress: '198.51.100.7' },
configurable: true,
});
next();
});
app.get('/metrics', createMetricsHandler(reg, { allowedHosts: ['0.0.0.0'] }));
const res = await request(app).get('/metrics');
expect(res.status).toBe(200);
});
it('allowedHosts custom list accepts listed IPs and rejects others', async () => {
const reg = new Registry();
createGatewayMetrics(reg, 'aao_gateway_h2');
// Listed IP → 200
const appOk = express();
appOk.use((req, _res, next) => {
Object.defineProperty(req, 'ip', { value: '10.0.0.5', configurable: true });
Object.defineProperty(req, 'socket', {
value: { remoteAddress: '10.0.0.5' },
configurable: true,
});
next();
});
appOk.get('/metrics', createMetricsHandler(reg, { allowedHosts: ['10.0.0.5'] }));
const r1 = await request(appOk).get('/metrics');
expect(r1.status).toBe(200);
// Unlisted IP → 403
const reg2 = new Registry();
createGatewayMetrics(reg2, 'aao_gateway_h3');
const appBlocked = express();
appBlocked.use((req, _res, next) => {
Object.defineProperty(req, 'ip', { value: '10.0.0.99', configurable: true });
Object.defineProperty(req, 'socket', {
value: { remoteAddress: '10.0.0.99' },
configurable: true,
});
next();
});
appBlocked.get('/metrics', createMetricsHandler(reg2, { allowedHosts: ['10.0.0.5'] }));
const r2 = await request(appBlocked).get('/metrics');
expect(r2.status).toBe(403);
});
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* Phase 3b — Express handler that exposes a Registry over HTTP.
*
* Returns the Prometheus text exposition format (text/plain;
* version=0.0.4) — same shape every node_exporter / cAdvisor / litellm
* `/metrics` endpoint produces. Both gateway and worker mount this on
* `/metrics`.
*
* Auth model (Phase 3b post-review hardening)
* ───────────────────────────────────────────
* The worker / gateway HTTP servers are user-facing, so /metrics MUST
* NOT be wide open by default — labels like `team` and `key_prefix`
* (= the first 8 chars of a stable virtual-key id) leak to anyone who
* can reach the port. We therefore gate the handler with **two**
* complementary mechanisms, both opt-in via config:
*
* 1. Bearer token (`metrics.bearer_token`): when set, requests must
* carry `Authorization: Bearer <token>`. Wins over the IP list —
* lets operators run Prometheus from an arbitrary subnet without
* embedding every scraper IP in config.
* 2. Client-IP allowlist (`metrics.allowed_hosts`): when no bearer is
* configured, only requests whose `req.ip` is in the allowlist
* pass. Default `['127.0.0.1', '::1', 'localhost']` so a stock
* deploy can be scraped from localhost (sidecar / SSH tunnel) and
* nothing else.
*
* To open the endpoint to the world you either set a strong bearer
* token or explicitly broaden `allowed_hosts`. There is no "no auth"
* code path other than the default localhost allowlist.
*
* Errors during render are extremely rare (would require prom-client to
* throw during string assembly) but if they do happen we surface a
* generic 500 so the Prometheus scrape job records the failure
* (`up == 0` for that target) instead of silently serving an empty
* payload — a 200 with no metrics looks like "no data" in Grafana,
* which is worse than an honest 500.
*/
import type { RequestHandler } from 'express';
import type { Registry } from 'prom-client';
import { logger } from '../logger.js';
export interface MetricsAuthOptions {
/**
* Optional bearer token. When present, requests must carry
* `Authorization: Bearer <token>` or get a 401. Takes precedence
* over the IP allowlist.
*/
bearerToken?: string;
/**
* Client-IP allowlist applied when no bearer token is configured.
* Defaults to `['127.0.0.1', '::1', 'localhost']` — localhost only.
* Include `0.0.0.0` to disable IP checks entirely (only do this when
* an outer reverse proxy / firewall handles access control).
*/
allowedHosts?: string[];
}
const DEFAULT_ALLOWED_HOSTS: ReadonlyArray<string> = ['127.0.0.1', '::1', 'localhost'];
/**
* Build a handler that scrapes the supplied Registry on each request.
* The handler is async — prom-client's metrics() returns a Promise so
* we await it before calling res.end.
*
* The optional `auth` argument enables Phase 3b post-review hardening:
* bearer-token auth (preferred) or a client-IP allowlist (default
* localhost-only). See module doc for the full model.
*/
export function createMetricsHandler(
registry: Registry,
auth?: MetricsAuthOptions,
): RequestHandler {
const allowed = new Set(
(auth?.allowedHosts && auth.allowedHosts.length > 0
? auth.allowedHosts
: DEFAULT_ALLOWED_HOSTS) as string[],
);
const allowAny = allowed.has('0.0.0.0') || allowed.has('*');
const bearerToken = auth?.bearerToken && auth.bearerToken.length > 0 ? auth.bearerToken : null;
return async function metricsHandler(req, res, _next): Promise<void> {
// 1. Bearer token (if configured) takes precedence over the IP
// allowlist. This is the path operators use when scraping from
// an arbitrary network — they trade IP discipline for token
// secrecy.
if (bearerToken) {
const authHeader = req.headers['authorization'];
const expected = `Bearer ${bearerToken}`;
if (typeof authHeader !== 'string' || authHeader !== expected) {
res.set('WWW-Authenticate', 'Bearer realm="metrics"').status(401).end();
return;
}
} else if (!allowAny) {
// 2. Fall back to a client-IP allowlist. We accept the request when
// req.ip OR the raw remote address matches an entry. IPv4-mapped
// IPv6 (::ffff:127.0.0.1) is normalized so a localhost rule works
// on dual-stack hosts. `0.0.0.0` / `*` in the list disables the
// check for shaved-down configurations (e.g., reverse proxy in
// front of the gateway).
const candidates: string[] = [];
if (typeof req.ip === 'string' && req.ip.length > 0) {
candidates.push(req.ip, req.ip.replace(/^::ffff:/, ''));
}
const remote = req.socket?.remoteAddress;
if (typeof remote === 'string' && remote.length > 0) {
candidates.push(remote, remote.replace(/^::ffff:/, ''));
}
const allowedHit = candidates.some(c => allowed.has(c));
if (!allowedHit) {
// 403, not 401: there's nothing the client can do — the IP isn't
// on the list. WWW-Authenticate would mislead a bearer client
// into retrying with a token that wouldn't be checked.
res.status(403).end();
return;
}
}
try {
const body = await registry.metrics();
res.setHeader('Content-Type', registry.contentType);
res.status(200).end(body);
} catch (err) {
// Logged at warn (not error) because a flaky default-metric
// collector shouldn't pager the on-call. Prometheus side will
// mark the target down and alert via its own rules.
logger.warn(
`[metrics-handler] failed to render metrics: ${err instanceof Error ? err.message : String(err)}`,
);
res.status(500).setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('# failed to render metrics\n');
}
};
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
createGatewayRegistry,
createWorkerRegistry,
getDefaultGatewayRegistry,
getDefaultWorkerRegistry,
resetAllRegistries,
} from './registry.js';
describe('metrics/registry', () => {
beforeEach(() => {
resetAllRegistries();
});
it('createGatewayRegistry returns a fresh Registry per call', () => {
const a = createGatewayRegistry('aao_gateway_test_a');
const b = createGatewayRegistry('aao_gateway_test_b');
expect(a).not.toBe(b);
});
it('default singletons are stable until reset', () => {
const first = getDefaultGatewayRegistry('aao_gateway_test_singleton');
const second = getDefaultGatewayRegistry('aao_gateway_test_singleton');
expect(first).toBe(second);
resetAllRegistries();
const third = getDefaultGatewayRegistry('aao_gateway_test_singleton');
expect(third).not.toBe(first);
});
it('gateway and worker singletons are independent', () => {
const g = getDefaultGatewayRegistry('aao_gateway_test_gw');
const w = getDefaultWorkerRegistry('aao_worker_test_wk');
expect(g).not.toBe(w);
});
it('default metrics are wired (process_cpu_user_seconds_total appears)', async () => {
const reg = createWorkerRegistry('aao_worker_dm');
// Default-metric collection registers some metrics lazily; call
// metrics() to force gathering.
const out = await reg.metrics();
// Process CPU is always present on Node + Linux.
expect(out).toContain('aao_worker_dm_process_cpu_user_seconds_total');
});
});
+97
View File
@@ -0,0 +1,97 @@
/**
* Phase 3b — shared Prometheus registry plumbing.
*
* Both gateway mode and worker mode get their own Registry instance
* (different prefixes, different metric sets) but they share this
* factory so the wiring stays consistent: every registry gets the
* default `process_*` / `nodejs_*` metrics from prom-client so SREs see
* cpu / event-loop / heap metrics on every AAO process without any
* extra config.
*
* Design:
* - `createGatewayRegistry()` / `createWorkerRegistry()` return distinct
* Registry instances. Tests pass these directly to avoid the
* process-singleton trap (one Vitest worker running many tests must
* not share metric state across cases).
* - `getDefaultGatewayRegistry()` / `getDefaultWorkerRegistry()` lazily
* create a singleton per process; called from bootstrap / bridge.
* - `resetAllRegistries()` is a test escape hatch. Production code never
* calls it.
*
* The label cardinality budget is enforced by call-site discipline (see
* the per-metric files). Prom-client doesn't cap labels itself, so a
* runaway labelset would silently grow memory until the next scrape —
* we keep label sets narrow and well-typed in the metric factory funcs.
*/
import { Registry, collectDefaultMetrics } from 'prom-client';
let defaultGatewayRegistry: Registry | null = null;
let defaultWorkerRegistry: Registry | null = null;
/**
* Stand up a fresh Registry with `process_*` / `nodejs_*` collected on
* scrape. The `prefix` param is forwarded to collectDefaultMetrics so
* default metric names match the gateway / worker namespace (e.g.
* `aao_gateway_process_cpu_user_seconds_total`).
*/
function createRegistry(prefix: string): Registry {
const reg = new Registry();
// Default labels let Grafana queries select by deployment without
// having to label every counter individually. Empty for now — operators
// typically add `instance` / `job` via the Prometheus scrape config.
reg.setDefaultLabels({});
collectDefaultMetrics({
register: reg,
// Slightly verbose names but keeps the namespace consistent.
prefix: `${prefix}_`,
});
return reg;
}
/**
* Build the gateway-side Registry. Use this from
* gateway/bootstrap.ts. Tests create their own to stay isolated.
*/
export function createGatewayRegistry(prefix: string = 'aao_gateway'): Registry {
return createRegistry(prefix);
}
/**
* Build the worker-side Registry. Use this from bridge/server.ts.
*/
export function createWorkerRegistry(prefix: string = 'aao_worker'): Registry {
return createRegistry(prefix);
}
/**
* Lazily create + return the process-singleton gateway registry. Bootstrap
* calls this once at startup; admin handlers reach in (rare) via
* getDefaultGatewayRegistry() to read current metric values.
*/
export function getDefaultGatewayRegistry(prefix: string = 'aao_gateway'): Registry {
if (!defaultGatewayRegistry) {
defaultGatewayRegistry = createGatewayRegistry(prefix);
}
return defaultGatewayRegistry;
}
/**
* Lazily create + return the process-singleton worker registry.
*/
export function getDefaultWorkerRegistry(prefix: string = 'aao_worker'): Registry {
if (!defaultWorkerRegistry) {
defaultWorkerRegistry = createWorkerRegistry(prefix);
}
return defaultWorkerRegistry;
}
/**
* Test-only escape hatch: drop both singletons so the next call rebuilds
* a fresh registry. Production code MUST NOT call this — clearing a
* live registry mid-scrape produces zeroed counters that look like
* regressions in Grafana.
*/
export function resetAllRegistries(): void {
defaultGatewayRegistry = null;
defaultWorkerRegistry = null;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Phase 3b post-review — tool_name label normalization.
*/
import { describe, it, expect } from 'vitest';
import { Registry } from 'prom-client';
import { normalizeToolNameForMetric, BUILTIN_TOOL_NAMES } from './tool-name-allowlist.js';
import { createWorkerMetrics } from './worker-metrics.js';
describe('normalizeToolNameForMetric', () => {
it('passes built-in tool names through verbatim', () => {
expect(normalizeToolNameForMetric('Read')).toBe('Read');
expect(normalizeToolNameForMetric('WebFetch')).toBe('WebFetch');
expect(normalizeToolNameForMetric('SpawnSubTask')).toBe('SpawnSubTask');
});
it('collapses every mcp__* name to a single mcp bucket', () => {
expect(normalizeToolNameForMetric('mcp__alpha__foo')).toBe('mcp');
expect(normalizeToolNameForMetric('mcp__beta__bar')).toBe('mcp');
expect(normalizeToolNameForMetric('mcp__github__create_issue')).toBe('mcp');
});
it('collapses anything else to unknown', () => {
expect(normalizeToolNameForMetric('some_random_tool')).toBe('unknown');
expect(normalizeToolNameForMetric('')).toBe('unknown');
expect(normalizeToolNameForMetric('not_a_real_tool_name')).toBe('unknown');
});
it('does not treat partial mcp_ prefix as MCP (must be `mcp__`)', () => {
expect(normalizeToolNameForMetric('mcp_foo')).toBe('unknown');
expect(normalizeToolNameForMetric('mcpwhatever')).toBe('unknown');
});
it('runtime tools (transition, complete) pass through', () => {
expect(normalizeToolNameForMetric('transition')).toBe('transition');
expect(normalizeToolNameForMetric('complete')).toBe('complete');
});
it('BUILTIN_TOOL_NAMES contains the documented set of built-ins', () => {
// Smoke-checks: ensure we didn't accidentally drop a known tool.
const required = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebFetch', 'BrowseWeb'];
for (const name of required) {
expect(BUILTIN_TOOL_NAMES.has(name)).toBe(true);
}
});
it('label cardinality stays bounded: many mcp__* names share one bucket', async () => {
const reg = new Registry();
const m = createWorkerMetrics(reg, 'aao_worker_n');
// Simulate 100 distinct mcp__* names.
for (let i = 0; i < 100; i += 1) {
m.toolCallsTotal
.labels({ tool_name: normalizeToolNameForMetric(`mcp__server${i}__tool${i}`), success: 'true' })
.inc();
}
const dump = await reg.metrics();
// All 100 calls should have collapsed into the single mcp bucket.
expect(dump).toMatch(/aao_worker_n_tool_calls_total\{tool_name="mcp",success="true"\} 100/);
// No mcp__ literals should leak into the metric body.
expect(dump).not.toMatch(/tool_name="mcp__/);
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Phase 3b post-review — collapse tool names into a bounded label set
* before they reach `aao_worker_tool_calls_total{tool_name}`.
*
* Without this normalization, a piece that calls `mcp__foo__bar` or a
* user-defined ad-hoc tool would inject a fresh `tool_name` label on
* every distinct name — `mcp__` names are user-controllable and can
* grow unbounded, especially through Brainstorm-generated piece
* variations. Once the cardinality explodes we either OOM prom-client
* or get rate-limited by the scrape side.
*
* Policy:
* - Known built-in tool name → pass through verbatim.
* - Any `mcp__*` name → collapse to single label `mcp`.
* - Anything else → collapse to `unknown`.
*
* Maintenance: when a new built-in tool ships, add its name to
* BUILTIN_TOOL_NAMES below. The set is intentionally hard-coded (not
* auto-derived from getToolDefs) so the metric label space is stable
* across hot-reloads — if a tool module fails to load at runtime its
* historical labels remain visible in Grafana instead of disappearing.
*/
/**
* Pseudo-tools the agent loop fires that aren't user-callable but
* still show up in the metric stream (transition / complete are
* control-flow tools the runtime injects).
*/
const RUNTIME_TOOLS: ReadonlyArray<string> = [
'transition',
'complete',
];
/**
* Built-in tools listed in `src/engine/tools/*.ts`. Keep alphabetized
* for easy maintenance + grep.
*/
const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
// core.ts
'Bash', 'Edit', 'Glob', 'Grep', 'Read', 'Write',
// web.ts
'DownloadFile', 'WebFetch', 'WebSearch',
// image.ts
'AnnotateImage', 'ReadImage',
// office.ts
'PdfToImages', 'ReadDocx', 'ReadExcel', 'ReadPdf', 'ReadPPTX',
'SplitDocxSections', 'SplitExcelSheets',
// data.ts
'SQLite',
// review.ts
'BatchReviewTextWithLLM', 'MergeReviewedResults',
// browser.ts
'BrowseWeb',
// knowledge.ts
'IngestDocument', 'IngestStatus', 'ListDocuments', 'ListNamespaces',
'SearchKnowledge',
// orchestration.ts
'SpawnSubTask',
// x.ts
'XPostDetail', 'XSearch', 'XUserPosts',
// maps.ts
'GetDirections', 'ReverseGeocode', 'SearchPlaces',
// youtube.ts
'GetYouTubeTranscript', 'SearchYouTube',
// amazon.ts
'SearchAmazon',
// speech.ts
'TranscribeAudio',
// checklist.ts
'CheckItem', 'CreateChecklist', 'GetChecklist',
// pieces.ts
'CreatePiece', 'GetPiece', 'ListPieces', 'UpdatePiece',
// docs.ts
'ReadToolDoc',
// mission.ts
'MissionUpdate',
// user-folder.ts
'ListUserAssets', 'ReadUserTemplate', 'RenderUserTemplate', 'RunUserScript',
// brainstorm.ts
'Brainstorm',
// app-docs.ts
'GetMyOrchestratorState', 'ListAppDocs', 'ReadAppDoc',
// ssh.ts
'SshDownload', 'SshExec', 'SshListConnections', 'SshUpload',
// ssh-console.ts
'SshConsoleEnsure', 'SshConsoleSendKeys', 'SshConsoleSnapshot',
// notes.ts
'ReadNote', 'SearchNotes', 'WriteNote',
// dashboard.ts
'UpdateDashboardWidget',
// ms-learn.ts (Microsoft Learn search)
'MsLearnFetch', 'MsLearnRead', 'MsLearnSearch', 'MsLearnSummarize',
// slide.ts
'CreateSlide',
];
export const BUILTIN_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
...BUILTIN_TOOL_NAMES_LIST,
...RUNTIME_TOOLS,
]);
/**
* Map an arbitrary tool name into the bounded label space.
*
* Examples:
* normalizeToolNameForMetric('Read') → 'Read'
* normalizeToolNameForMetric('mcp__alpha__x') → 'mcp'
* normalizeToolNameForMetric('user_custom') → 'unknown'
*/
export function normalizeToolNameForMetric(name: string): string {
if (BUILTIN_TOOL_NAMES.has(name)) return name;
if (name.startsWith('mcp__')) return 'mcp';
return 'unknown';
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Registry } from 'prom-client';
import { createWorkerMetrics } from './worker-metrics.js';
describe('metrics/worker-metrics', () => {
let reg: Registry;
beforeEach(() => {
reg = new Registry();
});
it('registers all 6 metrics under the supplied prefix', async () => {
createWorkerMetrics(reg, 'aao_worker_t');
const out = await reg.metrics();
for (const name of [
'aao_worker_t_jobs_total',
'aao_worker_t_active_jobs',
'aao_worker_t_job_duration_seconds',
'aao_worker_t_llm_calls_total',
'aao_worker_t_llm_call_duration_seconds',
'aao_worker_t_tool_calls_total',
]) {
expect(out).toContain(name);
}
});
it('activeJobs supports inc/dec', async () => {
const m = createWorkerMetrics(reg, 'aao_worker_g');
m.activeJobs.inc({ piece: 'chat', profile: 'main' });
m.activeJobs.inc({ piece: 'chat', profile: 'main' });
m.activeJobs.dec({ piece: 'chat', profile: 'main' });
const out = await reg.metrics();
expect(out).toMatch(/aao_worker_g_active_jobs\{piece="chat",profile="main"\} 1/);
});
it('tool calls split by success label', async () => {
const m = createWorkerMetrics(reg, 'aao_worker_tc');
m.toolCallsTotal.inc({ tool_name: 'Read', success: 'true' }, 5);
m.toolCallsTotal.inc({ tool_name: 'Read', success: 'false' }, 1);
const out = await reg.metrics();
expect(out).toMatch(/aao_worker_tc_tool_calls_total\{tool_name="Read",success="true"\} 5/);
expect(out).toMatch(/aao_worker_tc_tool_calls_total\{tool_name="Read",success="false"\} 1/);
});
it('LLM histogram observes seconds (not ms)', async () => {
const m = createWorkerMetrics(reg, 'aao_worker_llm');
m.llmCallDurationSeconds.observe(
{ worker_id: 'w1', backend_id: 'gpu-a', model: 'qwen3:8b' },
2.5,
);
const out = await reg.metrics();
expect(out).toMatch(/aao_worker_llm_llm_call_duration_seconds_sum\{[^}]+\} 2\.5/);
});
it('job-duration buckets fit minute-to-hour range', async () => {
const m = createWorkerMetrics(reg, 'aao_worker_jd');
m.jobDurationSeconds.observe(
{ piece: 'chat', status: 'succeeded', profile: 'main' },
75,
);
const out = await reg.metrics();
// The 120s bucket should be incremented (75 < 120)
expect(out).toMatch(/aao_worker_jd_job_duration_seconds_bucket\{[^}]*le="120"[^}]*\} 1/);
// The 60s bucket should NOT be incremented (75 > 60)
expect(out).toMatch(/aao_worker_jd_job_duration_seconds_bucket\{[^}]*le="60"[^}]*\} 0/);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Phase 3b — Worker-side Prometheus metrics.
*
* 6 metrics, prefixed `aao_worker_` by default. Worker mode emits these
* from worker.ts (job lifecycle) and agent-loop.ts (LLM call + tool
* call counters).
*
* Cardinality budget:
* - jobsTotal: pieces (~15) × statuses (~5) × profiles (~3) = ~225
* - llmCallsTotal: workerIds (~5) × backendIds (~10) × models (~5) =
* ~250 — the backendId axis matters when the worker talks to a
* proxy (LiteLLM or the AAO gateway itself).
* - toolCallsTotal: tool_names (~40) × success (2) = ~80
*
* Histograms emit bucket series proportional to label cardinality ×
* 10 default buckets; even worst-case we stay well below the informal
* 100k Prometheus cap.
*/
import { Counter, Gauge, Histogram, type Registry } from 'prom-client';
export interface WorkerMetrics {
/** Total jobs completed by terminal status. */
jobsTotal: Counter<'piece' | 'status' | 'profile'>;
/** Currently-running jobs (gauge, inc on start / dec on end). */
activeJobs: Gauge<'piece' | 'profile'>;
/** Total wall-clock seconds per job (start → terminal). */
jobDurationSeconds: Histogram<'piece' | 'status' | 'profile'>;
/** Outbound LLM calls. backend_id is the proxy-resolved physical backend or the worker_id when direct. */
llmCallsTotal: Counter<'worker_id' | 'backend_id' | 'model'>;
/** Wall-clock seconds per LLM call. */
llmCallDurationSeconds: Histogram<'worker_id' | 'backend_id' | 'model'>;
/** Tool invocations from the agent loop. success is "true" / "false". */
toolCallsTotal: Counter<'tool_name' | 'success'>;
}
export function createWorkerMetrics(
registry: Registry,
prefix: string = 'aao_worker',
): WorkerMetrics {
const jobsTotal = new Counter({
name: `${prefix}_jobs_total`,
help: 'Total jobs completed by piece / status / profile.',
labelNames: ['piece', 'status', 'profile'] as const,
registers: [registry],
});
const activeJobs = new Gauge({
name: `${prefix}_active_jobs`,
help: 'Currently-running jobs.',
labelNames: ['piece', 'profile'] as const,
registers: [registry],
});
const jobDurationSeconds = new Histogram({
name: `${prefix}_job_duration_seconds`,
help: 'Wall-clock job duration in seconds.',
labelNames: ['piece', 'status', 'profile'] as const,
// Override default buckets — jobs run from seconds to many minutes,
// so the HTTP-tuned defaults (5ms..10s) bury most observations.
buckets: [1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600],
registers: [registry],
});
const llmCallsTotal = new Counter({
name: `${prefix}_llm_calls_total`,
help: 'Outbound LLM API calls.',
labelNames: ['worker_id', 'backend_id', 'model'] as const,
registers: [registry],
});
const llmCallDurationSeconds = new Histogram({
name: `${prefix}_llm_call_duration_seconds`,
help: 'Wall-clock latency per LLM call.',
labelNames: ['worker_id', 'backend_id', 'model'] as const,
// LLM calls vary 100ms..120s; widen the bucket range.
buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 60, 120, 300],
registers: [registry],
});
const toolCallsTotal = new Counter({
name: `${prefix}_tool_calls_total`,
help: 'Tool invocations executed by the agent loop.',
labelNames: ['tool_name', 'success'] as const,
registers: [registry],
});
return {
jobsTotal,
activeJobs,
jobDurationSeconds,
llmCallsTotal,
llmCallDurationSeconds,
toolCallsTotal,
};
}
/** Closed enum of job terminal statuses for the jobsTotal counter. */
export type WorkerJobStatus =
| 'succeeded'
| 'failed'
| 'aborted'
| 'cancelled'
| 'waiting_human'
| 'error';