Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs-site/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,26 @@ Exported labels are fixed, bounded categories only: surface, HTTP status class,
status, and token type. Provider, model, account, request, conversation, error, and prompt
values are never emitted as labels or output.

When [rate limiting](#rate-limiting) is enabled, the registry additionally exposes bounded,
aggregate-only admission series:

- `opencodex_rate_limit_requests_total`: admission decisions by fixed `surface`, `source`
(`principal` or `overflow`), and `result` (`allowed` or `denied`) labels.
- `opencodex_rate_limit_websocket_reservations_total`: WebSocket concurrency reservation
outcomes by fixed `reason` label.
- `opencodex_rate_limit_websocket_connections` and
`opencodex_rate_limit_websocket_tracked_principals`: current admitted WebSocket
concurrency gauges.
- `opencodex_rate_limit_principal_buckets` and `opencodex_rate_limit_overflow_surfaces`:
current limiter bucket-allocation gauges.

The JSON snapshot carries the same data in an optional `rateLimit` subtree. Rate-limit
metrics appear only while rate limiting is enabled: default-off servers emit no rate-limit
series or subtree at all. Like the rest of the registry they are process-local (reset on
restart), and collection is read-only: scraping never consumes tokens, creates buckets, or
otherwise mutates limiter state, and no principal, fingerprint, credential, Origin, or
address values are ever emitted.

A minimal Prometheus scrape config, with the admin token supplied from a secret file rather
than inlined:

Expand Down
45 changes: 44 additions & 1 deletion src/observability/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { OcxUsage } from "../types";
import { getActiveTurnCount, isDraining } from "../server/lifecycle";
import type { RateLimitAggregateSnapshot } from "../server/rate-limit";
import {
appendRateLimitPrometheus,
projectRateLimitMetrics,
type RateLimitMetricsSnapshot,
} from "./rate-limit-projection";

export const REQUEST_DURATION_BUCKETS_MS = [
25,
Expand Down Expand Up @@ -72,6 +78,12 @@ export interface MetricsSnapshot {
type: MetricTokenType;
count: number;
}>;
/**
* Aggregate-only admission counters, present only while rate limiting is enabled and the
* registered collector produced a snapshot. Absent, disabled, or failing collectors omit the
* subtree entirely (fail closed) so default-off output is byte-identical to before.
*/
rateLimit?: RateLimitMetricsSnapshot;
}

export interface HistogramSnapshot {
Expand Down Expand Up @@ -234,6 +246,7 @@ export class RuntimeMetrics {
private readonly durations = new Map<MetricSurface, HistogramState>();
private readonly firstOutput = new Map<MetricSurface, HistogramState>();
private readonly tokenCounts = new Map<string, TokenCounterState>();
private rateLimitCollector: (() => Readonly<RateLimitAggregateSnapshot>) | null = null;

constructor(
private readonly processCollector: () => ProcessMetricsSnapshot = defaultProcessMetrics,
Expand All @@ -245,6 +258,18 @@ export class RuntimeMetrics {
this.durations.clear();
this.firstOutput.clear();
this.tokenCounts.clear();
// Test/server isolation boundary: a fresh registry must never render a previous server
// instance's admission counters through a stale collector.
this.rateLimitCollector = null;
}

/**
* Register (or clear, with null) the aggregate-only admission collector. The collector is
* invoked at most once per snapshot, read-only, and its output is projected into a detached
* copy, so metrics consumers can never mutate limiter state.
*/
setRateLimitCollector(collector: (() => Readonly<RateLimitAggregateSnapshot>) | null): void {
this.rateLimitCollector = collector;
}

recordRequest(entry: RequestMetricObservation): void {
Expand Down Expand Up @@ -285,7 +310,8 @@ export class RuntimeMetrics {

snapshot(): MetricsSnapshot {
const processSnapshot = normalizeProcessMetrics(this.processCollector());
return {
const rateLimit = this.collectRateLimit();
const snapshot: MetricsSnapshot = {
version: 1,
generatedAt: counterValue(this.now()),
process: processSnapshot,
Expand All @@ -301,6 +327,8 @@ export class RuntimeMetrics {
.sort((left, right) => surfaceRank(left.surface) - surfaceRank(right.surface)
|| tokenRank(left.type) - tokenRank(right.type)),
};
if (rateLimit) snapshot.rateLimit = rateLimit;
return snapshot;
}

prometheus(): string {
Expand Down Expand Up @@ -355,9 +383,24 @@ export class RuntimeMetrics {
help("opencodex_process_array_buffers_bytes", "gauge", "ArrayBuffer memory tracked by the runtime in bytes.");
lines.push(`opencodex_process_array_buffers_bytes ${metricNumber(snapshot.process.arrayBuffersBytes)}`);

// Render from the snapshot already taken above: the admission collector ran at most once
// for this scrape, and appending is read-only over the detached projection.
appendRateLimitPrometheus(lines, snapshot.rateLimit);

return `${lines.join("\n")}\n`;
}

/** Fail-closed collection: absent, disabled, or throwing collectors omit the subtree. */
private collectRateLimit(): RateLimitMetricsSnapshot | null {
const collector = this.rateLimitCollector;
if (!collector) return null;
try {
return projectRateLimitMetrics(collector());
} catch {
return null;
}
}

private addTokens(surface: MetricSurface, type: MetricTokenType, amount: number): void {
const key = tokenKey(surface, type);
const token = this.tokenCounts.get(key) ?? { surface, type, count: 0 };
Expand Down
158 changes: 158 additions & 0 deletions src/observability/rate-limit-projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { RATE_LIMIT_SURFACES, type RateLimitStatsRow, type RateLimitSurface } from "../ratelimit";
import type { RateLimitAggregateSnapshot } from "../server/rate-limit";

export type RateLimitMetricSource = "principal" | "overflow";
export type RateLimitMetricResult = "allowed" | "denied";
export type RateLimitWebSocketMetricReason =
| "accepted"
| "global_limit"
| "principal_limit"
| "principal_capacity";

export interface RateLimitMetricsSnapshot {
enabled: true;
requests: Array<{
surface: RateLimitSurface;
source: RateLimitMetricSource;
result: RateLimitMetricResult;
count: number;
}>;
buckets: {
principals: number;
overflowSurfaces: number;
};
websocket: {
currentGlobal: number;
trackedPrincipals: number;
reservations: Array<{
reason: RateLimitWebSocketMetricReason;
count: number;
}>;
};
}

const SURFACE_ORDER: readonly RateLimitSurface[] = RATE_LIMIT_SURFACES;
const SOURCE_ORDER: readonly RateLimitMetricSource[] = ["principal", "overflow"];
const RESULT_ORDER: readonly RateLimitMetricResult[] = ["allowed", "denied"];
const WS_REASON_ORDER: readonly RateLimitWebSocketMetricReason[] = [
"accepted",
"global_limit",
"principal_limit",
"principal_capacity",
];

function boundedCounter(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.trunc(value)));
}

function isSurface(value: unknown): value is RateLimitSurface {
return typeof value === "string" && (SURFACE_ORDER as readonly string[]).includes(value);
}

function isSource(value: unknown): value is RateLimitMetricSource {
return value === "principal" || value === "overflow";
}

function isResult(value: unknown): value is RateLimitMetricResult {
return value === "allowed" || value === "denied";
}

function normalizedRequests(rows: readonly RateLimitStatsRow[]): RateLimitMetricsSnapshot["requests"] {
return rows
.filter(row => isSurface(row.surface) && isSource(row.source) && isResult(row.result))
.map(row => ({
surface: row.surface,
source: row.source,
result: row.result,
count: boundedCounter(row.count),
}))
.sort((left, right) => SURFACE_ORDER.indexOf(left.surface) - SURFACE_ORDER.indexOf(right.surface)
|| SOURCE_ORDER.indexOf(left.source) - SOURCE_ORDER.indexOf(right.source)
|| RESULT_ORDER.indexOf(left.result) - RESULT_ORDER.indexOf(right.result));
}

/**
* Copy one aggregate-only admission snapshot into the metrics DTO.
*
* The input type cannot carry principals, fingerprints, credentials, addresses, Origins, routes,
* providers, models, requests, conversations, prompts or errors. Values are copied, clamped and
* sorted so callers cannot mutate limiter state through a metrics response.
*/
export function projectRateLimitMetrics(
input: Readonly<RateLimitAggregateSnapshot> | null | undefined,
): RateLimitMetricsSnapshot | null {
if (input?.enabled !== true) return null;
const stats = input.websocket.stats;
const reservations: RateLimitMetricsSnapshot["websocket"]["reservations"] = [
{ reason: "accepted", count: boundedCounter(stats.accepted) },
{ reason: "global_limit", count: boundedCounter(stats.deniedGlobal) },
{ reason: "principal_limit", count: boundedCounter(stats.deniedPrincipal) },
{ reason: "principal_capacity", count: boundedCounter(stats.deniedPrincipalCapacity) },
];
reservations.sort((left, right) => WS_REASON_ORDER.indexOf(left.reason) - WS_REASON_ORDER.indexOf(right.reason));

return {
enabled: true,
requests: normalizedRequests(input.requests),
buckets: {
principals: boundedCounter(input.buckets.principals),
overflowSurfaces: boundedCounter(input.buckets.overflowSurfaces),
},
websocket: {
currentGlobal: boundedCounter(input.websocket.globalCount),
trackedPrincipals: boundedCounter(input.websocket.trackedPrincipals),
reservations,
},
};
}

function escapeLabel(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll("\n", "\\n").replaceAll('"', '\\"');
}

function labels(values: Record<string, string>): string {
return `{${Object.entries(values)
.map(([name, value]) => `${name}="${escapeLabel(value)}"`)
.join(",")}}`;
}

/** Append only bounded, aggregate rate-limit series. This function is read-only. */
Comment thread
cursor[bot] marked this conversation as resolved.
export function appendRateLimitPrometheus(
lines: string[],
snapshot: Readonly<RateLimitMetricsSnapshot> | null | undefined,
): void {
if (!snapshot) return;

lines.push("# HELP opencodex_rate_limit_requests_total Admission decisions by bounded surface, bucket source and result.");
lines.push("# TYPE opencodex_rate_limit_requests_total counter");
for (const row of snapshot.requests) {
lines.push(`opencodex_rate_limit_requests_total${labels({
surface: row.surface,
source: row.source,
result: row.result,
})} ${row.count}`);
}

lines.push("# HELP opencodex_rate_limit_websocket_reservations_total WebSocket concurrency reservation outcomes by bounded reason.");
lines.push("# TYPE opencodex_rate_limit_websocket_reservations_total counter");
for (const row of snapshot.websocket.reservations) {
lines.push(`opencodex_rate_limit_websocket_reservations_total${labels({ reason: row.reason })} ${row.count}`);
}

lines.push("# HELP opencodex_rate_limit_websocket_connections Current admitted WebSocket concurrency.");
lines.push("# TYPE opencodex_rate_limit_websocket_connections gauge");
lines.push(`opencodex_rate_limit_websocket_connections ${snapshot.websocket.currentGlobal}`);

lines.push("# HELP opencodex_rate_limit_websocket_tracked_principals Current number of principals with admitted WebSockets.");
lines.push("# TYPE opencodex_rate_limit_websocket_tracked_principals gauge");
lines.push(`opencodex_rate_limit_websocket_tracked_principals ${snapshot.websocket.trackedPrincipals}`);

lines.push("# HELP opencodex_rate_limit_principal_buckets Current allocated principal token buckets.");
lines.push("# TYPE opencodex_rate_limit_principal_buckets gauge");
lines.push(`opencodex_rate_limit_principal_buckets ${snapshot.buckets.principals}`);

lines.push("# HELP opencodex_rate_limit_overflow_surfaces Current surfaces using a bounded shared overflow bucket.");
lines.push("# TYPE opencodex_rate_limit_overflow_surfaces gauge");
lines.push(`opencodex_rate_limit_overflow_surfaces ${snapshot.buckets.overflowSurfaces}`);
}
1 change: 1 addition & 0 deletions src/ratelimit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
type RateLimitPrincipalKind,
} from "./principal";
export {
RATE_LIMIT_SURFACES,
TokenBucketLimiter,
validateRateLimitPolicy,
type RateLimitDecision,
Expand Down
36 changes: 15 additions & 21 deletions src/ratelimit/token-bucket.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import type { RateLimitPrincipal } from "./principal";

export type RateLimitSurface =
| "management"
| "responses-http"
| "responses-websocket"
| "chat-completions"
| "claude-messages"
| "images"
| "search"
| "live"
| "model-discovery";
/** Authoritative, ordered list of admission surfaces. Derive checks and ordering from this. */
export const RATE_LIMIT_SURFACES = [
"management",
"responses-http",
"responses-websocket",
"chat-completions",
"claude-messages",
"images",
"search",
"live",
"model-discovery",
] as const;

export type RateLimitSurface = (typeof RATE_LIMIT_SURFACES)[number];

export interface RateLimitPolicy {
requestsPerMinute: number;
Expand Down Expand Up @@ -48,17 +52,7 @@ interface BucketState {
policy: RateLimitPolicy;
}

const SURFACE_ORDER: readonly RateLimitSurface[] = [
"management",
"responses-http",
"responses-websocket",
"chat-completions",
"claude-messages",
"images",
"search",
"live",
"model-discovery",
];
const SURFACE_ORDER: readonly RateLimitSurface[] = RATE_LIMIT_SURFACES;

function validatePositiveFinite(value: number, name: string): number {
if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be greater than zero`);
Expand Down
6 changes: 6 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,12 @@ export function startServer(port?: number) {
// per server, created before the listener can serve a request; repeated startServer(0) in
// tests therefore never leaks buckets or WebSocket reservations across server instances.
const admission = createServerAdmissionControl(config, managementAuth);
// Metrics lane (structure/plugin-metrics-ratelimit-benchmarks.md §3): register the
// aggregate-only admission collector once per server start, before the listener can serve a
// scrape. Default-off explicitly clears any stale collector from a prior startServer(0)
// instance so a disabled server never renders another instance's counters. Snapshots are
// projected on demand — no per-route refresh, no file I/O.
runtimeMetrics.setRateLimitCollector(admission.enabled ? () => admission.snapshot() : null);
// Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update
// adding/dropping models reaches existing configs on start — not just fresh installs.
reconcileOAuthProviders(config);
Expand Down
Loading
Loading