Skip to content

feat: Prometheus metrics endpoint#63

Open
djimit wants to merge 3 commits into
mainfrom
feat/prometheus-metrics
Open

feat: Prometheus metrics endpoint#63
djimit wants to merge 3 commits into
mainfrom
feat/prometheus-metrics

Conversation

@djimit

@djimit djimit commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

P3 (operational depth) from the APEX validation plan: a real scrape target so the fleet can be monitored and alerted on. The existing /api/observability/metrics is JWT-gated JSON for the dashboard; Prometheus needs the text format and token auth.

  • GET /metrics, Prometheus text format 0.0.4, zero new dependencies (format emitted by hand)
  • Gauges computed per scrape from SQLite — no in-process counters, so restarts and multi-node deploys need no state: djimitflo_{tasks,agents,loop_runs,worker_leases,approvals,work_items}{status=...}, djimitflo_openmythos_score{agent=...} (ties the governance benchmark into alerting), djimitflo_ws_clients, process uptime/RSS
  • Default-off: 404 unless METRICS_TOKEN is set; scrapers send Authorization: Bearer <token> (constant-time compare). Missing tables on a deploy degrade to header-only output instead of erroring
  • Label values escaped per the exposition spec

Validation

  • 4 new tests (invisible-when-unarmed, auth rejection, gauge/format assertions with seeded rows, label escaping); full server suite 1,217 green; type-check + lint clean
  • Live: booted with METRICS_TOKEN — no/wrong token → 401, valid scrape returned the gauges in exposition format

Example alert this enables: djimitflo_openmythos_score{agent="nightly:llama3.1:8b"} < 2.5 → governance regression page.

🤖 Generated with Claude Code

GET /metrics in Prometheus text format 0.0.4, computed per scrape from
SQLite (no in-process counters — restart-safe, multi-node friendly):
tasks/agents/loop_runs/worker_leases/approvals/work_items by status,
latest OpenMythos governance score per agent, WebSocket client count,
process uptime and RSS.

Default-off: 404 unless METRICS_TOKEN is set; scrapers authenticate
with Authorization: Bearer <token> (constant-time compare) since JWT
doesn't fit Prometheus. No new dependencies — the text format is
emitted by hand.

Verified live: booted with METRICS_TOKEN; missing/wrong tokens got 401,
a valid scrape returned the gauges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/server/src/index.ts Fixed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a Prometheus metrics exposition endpoint (GET /metrics) that exports various system gauges, OpenMythos scores, WebSocket client counts, and process metrics, along with corresponding unit tests. The review feedback identifies a security vulnerability in the token comparison where timingSafeEqual can throw a RangeError or leak token length, recommending that both tokens be hashed with SHA-256 before comparison. Additionally, the feedback suggests caching the prepared SQLite statements to improve performance by avoiding recompilation on every scrape request.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

* process, so restarts and multi-node deploys need no extra state.
*/

import { timingSafeEqual } from 'crypto';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

To prevent timing attacks that leak the length of the expected token, and to avoid potential RangeError crashes in timingSafeEqual when comparing buffers of different lengths, we should hash both the expected and provided tokens using a fixed-length hash (like SHA-256) before performing the comparison.

Suggested change
import { timingSafeEqual } from 'crypto';
import { createHash, timingSafeEqual } from 'crypto';

Comment on lines +18 to +24
function authorized(req: Request): boolean {
const token = process.env.METRICS_TOKEN;
if (!token) return false;
const expected = Buffer.from(`Bearer ${token}`);
const provided = Buffer.from(req.headers.authorization || '');
return provided.length === expected.length && timingSafeEqual(provided, expected);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Implement the SHA-256 hashing approach to securely compare the tokens in constant time without leaking the token length or risking a RangeError crash.

function authorized(req: Request): boolean {
  const token = process.env.METRICS_TOKEN;
  if (!token) return false;
  const expected = `Bearer ${token}`;
  const provided = req.headers.authorization || '';
  const expectedHash = createHash('sha256').update(expected).digest();
  const providedHash = createHash('sha256').update(provided).digest();
  return timingSafeEqual(expectedHash, providedHash);
}

Comment on lines +26 to +34
export function createMetricsHandler(db: Database, getWsClients?: () => number) {
const statusGauge = (name: string, help: string, table: string): string[] => {
const lines = [`# HELP ${name} ${help}`, `# TYPE ${name} gauge`];
try {
const rows = db.prepare(`SELECT status, COUNT(*) AS n FROM ${table} GROUP BY status`).all() as Array<{ status: string; n: number }>;
for (const row of rows) lines.push(`${name}{status="${escapeLabel(String(row.status))}"} ${row.n}`);
} catch { /* table absent on this deploy — emit the header only */ }
return lines;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Preparing SQL statements on every single scrape request is inefficient and can become a performance bottleneck. We should lazily prepare and cache the SQL statements inside the createMetricsHandler closure so they are compiled only once per statement.

export function createMetricsHandler(db: Database, getWsClients?: () => number) {
  const statementCache = new Map<string, ReturnType<typeof db.prepare>>();

  const getStatement = (sql: string) => {
    let stmt = statementCache.get(sql);
    if (!stmt) {
      stmt = db.prepare(sql);
      statementCache.set(sql, stmt);
    }
    return stmt;
  };

  const statusGauge = (name: string, help: string, table: string): string[] => {
    const lines = [`# HELP ${name} ${help}`, `# TYPE ${name} gauge`];
    try {
      const stmt = getStatement(`SELECT status, COUNT(*) AS n FROM ${table} GROUP BY status`);
      const rows = stmt.all() as Array<{ status: string; n: number }>;
      for (const row of rows) lines.push(`${name}{status="${escapeLabel(String(row.status))}"} ${row.n}`);
    } catch { /* table absent on this deploy — emit the header only */ }
    return lines;
  };

Comment on lines +59 to +67
try {
const rows = db.prepare(`
SELECT agent_id, overall_score, MAX(finished_at) AS finished_at
FROM openmythos_eval_runs WHERE status = 'completed' GROUP BY agent_id
`).all() as Array<{ agent_id: string; overall_score: number }>;
for (const row of rows) {
lines.push(`djimitflo_openmythos_score{agent="${escapeLabel(row.agent_id)}"} ${row.overall_score}`);
}
} catch { /* eval table absent */ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the cached statement helper getStatement here as well to avoid preparing the query on every request.

Suggested change
try {
const rows = db.prepare(`
SELECT agent_id, overall_score, MAX(finished_at) AS finished_at
FROM openmythos_eval_runs WHERE status = 'completed' GROUP BY agent_id
`).all() as Array<{ agent_id: string; overall_score: number }>;
for (const row of rows) {
lines.push(`djimitflo_openmythos_score{agent="${escapeLabel(row.agent_id)}"} ${row.overall_score}`);
}
} catch { /* eval table absent */ }
try {
const stmt = getStatement(`
SELECT agent_id, overall_score, MAX(finished_at) AS finished_at
FROM openmythos_eval_runs WHERE status = 'completed' GROUP BY agent_id
`);
const rows = stmt.all() as Array<{ agent_id: string; overall_score: number }>;
for (const row of rows) {
lines.push(`djimitflo_openmythos_score{agent="${escapeLabel(row.agent_id)}"} ${row.overall_score}`);
}
} catch { /* eval table absent */ }

@kilo-code-bot

kilo-code-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Kilo Code Review could not run — your account is out of credits.

Add credits or switch to a free model to enable reviews on this change.

djimit and others added 2 commits July 15, 2026 21:13
Reuses the existing RateLimiter: 300 scrapes / 15 min per IP, every
request counted, 429 beyond — generous for any Prometheus interval,
tight enough to blunt token brute-forcing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The custom in-repo RateLimiter is real rate limiting, but CodeQL's
js/missing-rate-limiting only recognizes known middleware packages, so
the alert survived the previous fix. Swap to express-rate-limit (same
policy: 300 scrapes / 15 min per IP) mounted as route middleware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants