{error ? (
@@ -262,6 +297,46 @@ export function OpsDashboard() {
) : null}
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+ Source: in-process metrics snapshot
+ {metrics?.timestamp ? ` (${formatShortTime(metrics.timestamp)})` : ""} β identical to{" "}
+ GET /api/metrics. Empty snapshot reads as zeroes.
+
+
+
diff --git a/src/lib/observability/metrics.test.ts b/src/lib/observability/metrics.test.ts
index cd469b57..7499d859 100644
--- a/src/lib/observability/metrics.test.ts
+++ b/src/lib/observability/metrics.test.ts
@@ -7,10 +7,15 @@ import {
ALLOWED_OUTCOMES,
ALLOWED_RESULTS,
ALLOWED_ROUTES,
+ ALLOW_OUTCOMES,
+ DENY_OUTCOMES,
MAX_CARDINALITY,
+ SUBMIT_FAILURE_RESULTS,
escapePrometheusLabelValue,
getDecisionOutcomeCounts,
getMetricsSnapshot,
+ getOpsCounters,
+ getRateLimitRejectionCount,
getStellarSubmitResultCounts,
isAllowedLabelKey,
isAllowedMetricName,
@@ -18,6 +23,7 @@ import {
normalizeRoute,
recordApiMetric,
recordDecisionOutcome,
+ recordRateLimitRejection,
recordStellarSubmitResult,
renderHelpLine,
renderMetricLine,
@@ -109,10 +115,102 @@ describe("observability metrics", () => {
it("resets new counters alongside existing buckets", () => {
recordDecisionOutcome("WARN");
recordStellarSubmitResult("success");
+ recordRateLimitRejection();
resetMetrics();
expect(getDecisionOutcomeCounts().size).toBe(0);
expect(getStellarSubmitResultCounts().size).toBe(0);
+ expect(getRateLimitRejectionCount()).toBe(0);
+ });
+
+ describe("ops counters (dashboard <-> metrics parity)", () => {
+ it("zeroes every counter for an empty snapshot", () => {
+ expect(getMetricsSnapshot().counters).toEqual({
+ allow: 0,
+ deny: 0,
+ rateLimit: 0,
+ submitFailures: 0,
+ });
+ });
+
+ it("rolls allow/deny decisions up by outcome", () => {
+ recordDecisionOutcome("APPROVE");
+ recordDecisionOutcome("WARN");
+ recordDecisionOutcome("REQUIRE_APPROVAL");
+ recordDecisionOutcome("BLOCK");
+
+ expect(getOpsCounters().allow).toBe(2);
+ expect(getOpsCounters().deny).toBe(2);
+ });
+
+ it("counts rate-limit rejections", () => {
+ recordRateLimitRejection();
+ recordRateLimitRejection();
+ recordRateLimitRejection();
+
+ expect(getRateLimitRejectionCount()).toBe(3);
+ expect(getMetricsSnapshot().counters.rateLimit).toBe(3);
+ });
+
+ it("counts submit failures but treats replays and successes as non-failures", () => {
+ recordStellarSubmitResult("success");
+ recordStellarSubmitResult("idempotency_replay");
+ for (const result of SUBMIT_FAILURE_RESULTS) {
+ recordStellarSubmitResult(result);
+ }
+
+ expect(getMetricsSnapshot().counters.submitFailures).toBe(SUBMIT_FAILURE_RESULTS.length);
+ expect(SUBMIT_FAILURE_RESULTS).not.toContain("success");
+ expect(SUBMIT_FAILURE_RESULTS).not.toContain("idempotency_replay");
+ });
+
+ it("never derives counters from request buckets", () => {
+ recordApiMetric({ route: "/api/decision", method: "POST", statusCode: 429, durationMs: 5 });
+
+ const snapshot = getMetricsSnapshot();
+ expect(snapshot.totals.errorCount).toBe(1);
+ expect(snapshot.counters).toEqual({
+ allow: 0,
+ deny: 0,
+ rateLimit: 0,
+ submitFailures: 0,
+ });
+ });
+
+ it("exposes the same numbers through the JSON snapshot and the prometheus body", () => {
+ recordDecisionOutcome("APPROVE");
+ recordDecisionOutcome("BLOCK");
+ recordRateLimitRejection();
+ recordStellarSubmitResult("idempotency_conflict");
+
+ const { counters } = getMetricsSnapshot();
+ const output = toPrometheusText();
+
+ expect(output).toContain(`fortexa_decisions_allowed_total ${counters.allow}`);
+ expect(output).toContain(`fortexa_decisions_denied_total ${counters.deny}`);
+ expect(output).toContain(`fortexa_rate_limit_rejections_total ${counters.rateLimit}`);
+ expect(output).toContain(`fortexa_stellar_submit_failures_total ${counters.submitFailures}`);
+ expect(output).toContain("# TYPE fortexa_decisions_allowed_total counter");
+ expect(output).toContain("# TYPE fortexa_decisions_denied_total counter");
+ expect(output).toContain("# TYPE fortexa_rate_limit_rejections_total counter");
+ expect(output).toContain("# TYPE fortexa_stellar_submit_failures_total counter");
+ });
+
+ it("emits zero-initialised counters so alerts never break on a fresh process", () => {
+ const output = toPrometheusText();
+
+ expect(output).toContain("fortexa_decisions_allowed_total 0");
+ expect(output).toContain("fortexa_decisions_denied_total 0");
+ expect(output).toContain("fortexa_rate_limit_rejections_total 0");
+ expect(output).toContain("fortexa_stellar_submit_failures_total 0");
+ });
+
+ it("keeps allow/deny groups aligned with the exported outcome allowlist", () => {
+ expect(ALLOW_OUTCOMES.every((outcome) => ALLOWED_OUTCOMES.has(outcome))).toBe(true);
+ expect(DENY_OUTCOMES.every((outcome) => ALLOWED_OUTCOMES.has(outcome))).toBe(true);
+ expect(new Set([...ALLOW_OUTCOMES, ...DENY_OUTCOMES]).size).toBe(ALLOWED_OUTCOMES.size);
+ expect(SUBMIT_FAILURE_RESULTS.every((result) => ALLOWED_RESULTS.has(result))).toBe(true);
+ });
});
describe("#205 redaction-aware metrics", () => {
@@ -256,11 +354,11 @@ describe("observability metrics", () => {
recordDecisionOutcome("G" + "A".repeat(55));
// @ts-expect-error: testing freetext injection guard
recordDecisionOutcome("APPROVE; wallet=G123");
- // @ts-expect-error non-allowlisted freetext outcome must be rejected at runtime
+ // @ts-expect-error free-text value rejected by the runtime allowlist guard
recordDecisionOutcome("free-text");
- // @ts-expect-error non-allowlisted submit result must be rejected at runtime
+ // @ts-expect-error unknown submit result rejected by the runtime allowlist guard
recordStellarSubmitResult("wallet_leak_payload");
- // @ts-expect-error non-allowlisted submit result must be rejected at runtime
+ // @ts-expect-error wallet-shaped result rejected by the runtime allowlist guard
recordStellarSubmitResult("G12345");
expect(getDecisionOutcomeCounts().size).toBe(0);
@@ -280,9 +378,9 @@ describe("observability metrics", () => {
recordApiMetric({ route: wallet, method: "POST", statusCode: 200, durationMs: 10 });
recordApiMetric({ route: `/api/decision?wallet=${wallet}`, method: "POST", statusCode: 200, durationMs: 10 });
// Also try to inject via outcome/result with wallet-like string (should be dropped)
- // @ts-expect-error wallet-shaped outcome must be rejected at runtime
+ // @ts-expect-error wallet address rejected by the runtime allowlist guard
recordDecisionOutcome(wallet);
- // @ts-expect-error wallet-shaped submit result must be rejected at runtime
+ // @ts-expect-error wallet address rejected by the runtime allowlist guard
recordStellarSubmitResult(wallet);
const output = toPrometheusText();
diff --git a/src/lib/observability/metrics.ts b/src/lib/observability/metrics.ts
index 2fd08caa..b1665976 100644
--- a/src/lib/observability/metrics.ts
+++ b/src/lib/observability/metrics.ts
@@ -1,391 +1,391 @@
-import { redactMetricText } from "@/lib/observability/redact";
-
-type MetricKey = `${string}:${string}`;
-
-export type DecisionOutcome = "APPROVE" | "WARN" | "REQUIRE_APPROVAL" | "BLOCK";
-export type StellarSubmitResult =
- | "success"
- | "horizon_failure"
- | "validation_failure"
- | "idempotency_replay"
- | "idempotency_conflict"
- | "idempotency_in_flight"
- | "source_wallet_mismatch";
-
-export const ALLOWED_ROUTES = new Set([
- "/api/auth/challenge",
- "/api/auth/login",
- "/api/auth/logout",
- "/api/auth/refresh",
- "/api/auth/session",
- "/api/auth/wallet/revoke",
- "/api/decision",
- "/api/policy",
- "/api/policy/history",
- "/api/policy/validate",
- "/api/policy/simulate",
- "/api/policy/rollback",
- "/api/policy/rollback/preview",
- "/api/agent/plan",
- "/api/health",
- "/api/metrics",
- "/api/stellar/balance",
- "/api/stellar/build-payment",
- "/api/stellar/fund",
- "/api/stellar/pay",
- "/api/stellar/setup",
- "/api/stellar/submit-signed",
- "/api/audit",
- "/api/audit/export",
- "/api/audit/integrity",
- "/other",
-]);
-
-export const ALLOWED_METHODS = new Set([
- "GET",
- "POST",
- "PUT",
- "DELETE",
- "PATCH",
- "HEAD",
- "OPTIONS",
-]);
-
-export const ALLOWED_OUTCOMES: ReadonlySet = new Set([
- "APPROVE",
- "WARN",
- "REQUIRE_APPROVAL",
- "BLOCK",
-]);
-
-export const ALLOWED_RESULTS: ReadonlySet = new Set([
- "success",
- "horizon_failure",
- "validation_failure",
- "idempotency_replay",
- "idempotency_conflict",
- "idempotency_in_flight",
- "source_wallet_mismatch",
-]);
-
-/**
- * Maximum distinct route+method series before new series are dropped.
- * This is a second-layer guard: primary guard is the allowlist which
- * collapses arbitrary user-controlled values to "/other".
- * Set to accommodate all legitimate routeΓmethod combinations (ALLOWED_ROUTES Γ ALLOWED_METHODS)
- * while still bounding explosion if the allowlist is misconfigured.
- */
-export const MAX_CARDINALITY = 200;
-
-/**
- * Documented metric names (see docs/observability.md). Anything else is
- * rejected by the exporter: series names must never carry payment data
- * (destination, memo, wallet) or attacker-controlled free text.
- */
-export const ALLOWED_METRIC_NAMES: ReadonlySet = new Set([
- "fortexa_requests_total",
- "fortexa_request_errors_total",
- "fortexa_request_duration_ms_p95",
- "fortexa_decision_outcomes_total",
- "fortexa_stellar_submit_results_total",
-]);
-
-/**
- * The only label keys allowed on an exported series. In particular
- * `destination` and `memo` are rejected: they leak payment data and
- * blow up cardinality.
- */
-export const ALLOWED_LABEL_KEYS: ReadonlySet = new Set([
- "route",
- "method",
- "outcome",
- "result",
-]);
-
-export function isAllowedMetricName(name: string): boolean {
- return typeof name === "string" && ALLOWED_METRIC_NAMES.has(name);
-}
-
-export function isAllowedLabelKey(key: string): boolean {
- return typeof key === "string" && ALLOWED_LABEL_KEYS.has(key);
-}
-
-const decisionOutcomeCounts = new Map();
-const stellarSubmitResultCounts = new Map();
-
-type MetricBucket = {
- route: string;
- method: string;
- totalCount: number;
- errorCount: number;
- totalDurationMs: number;
- durationsMs: number[];
- lastStatusCode: number;
- lastSeenAt: string;
-};
-
-const buckets = new Map();
-const MAX_DURATIONS = 500;
-
-const WALLET_PATTERN = /G[A-Z2-7]{55}/;
-const FREE_TEXT_PATTERN = /[\s\n\r\t"'`]/;
-
-export function normalizeRoute(route: string): string {
- if (!route || typeof route !== "string") {
- return "/other";
- }
- // Strip query string and hash fragment β those can contain free-text / wallet data
- let normalized = route.trim().split("?")[0].split("#")[0] ?? "";
- // Remove trailing slash (except root)
- if (normalized.length > 1 && normalized.endsWith("/")) {
- normalized = normalized.slice(0, -1);
- }
- // Drop overly long values (likely free-text injection)
- if (normalized.length === 0 || normalized.length > 128) {
- return "/other";
- }
- // Drop values containing Stellar wallet addresses
- if (WALLET_PATTERN.test(normalized)) {
- return "/other";
- }
- // Drop values containing whitespace/quotes (free-text)
- if (FREE_TEXT_PATTERN.test(normalized)) {
- return "/other";
- }
- // Only allow explicitly listed routes; everything else collapses to /other
- if (ALLOWED_ROUTES.has(normalized)) {
- return normalized;
- }
- return "/other";
-}
-
-export function normalizeMethod(method: string): string {
- if (!method || typeof method !== "string") {
- return "UNKNOWN";
- }
- const upper = method.trim().toUpperCase();
- if (ALLOWED_METHODS.has(upper)) {
- return upper;
- }
- return "UNKNOWN";
-}
-
-export function escapePrometheusLabelValue(value: string): string {
- return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
-}
-
-function keyOf(route: string, method: string): MetricKey {
- return `${method.toUpperCase()}:${route}`;
-}
-
-function percentile(values: number[], p: number) {
- if (values.length === 0) {
- return 0;
- }
-
- const sorted = [...values].sort((left, right) => left - right);
- const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
- return sorted[index] ?? 0;
-}
-
-export function recordApiMetric(input: {
- route: string;
- method: string;
- statusCode: number;
- durationMs: number;
-}) {
- const sanitizedRoute = normalizeRoute(input.route);
- const sanitizedMethod = normalizeMethod(input.method);
- const key = keyOf(sanitizedRoute, sanitizedMethod);
-
- // Cardinality guard: drop new series once MAX_CARDINALITY is reached
- if (!buckets.has(key) && buckets.size >= MAX_CARDINALITY) {
- return;
- }
-
- const current = buckets.get(key) ?? {
- route: sanitizedRoute,
- method: sanitizedMethod,
- totalCount: 0,
- errorCount: 0,
- totalDurationMs: 0,
- durationsMs: [],
- lastStatusCode: 0,
- lastSeenAt: new Date().toISOString(),
- };
-
- current.totalCount += 1;
- current.totalDurationMs += input.durationMs;
- current.lastStatusCode = input.statusCode;
- current.lastSeenAt = new Date().toISOString();
-
- if (input.statusCode >= 400) {
- current.errorCount += 1;
- }
-
- current.durationsMs.push(input.durationMs);
- if (current.durationsMs.length > MAX_DURATIONS) {
- current.durationsMs.splice(0, current.durationsMs.length - MAX_DURATIONS);
- }
-
- buckets.set(key, current);
-}
-
-export function getMetricsSnapshot() {
- const byRoute = Array.from(buckets.values()).map((bucket) => {
- const avgDurationMs = bucket.totalCount > 0 ? bucket.totalDurationMs / bucket.totalCount : 0;
- const p95DurationMs = percentile(bucket.durationsMs, 95);
- const errorRate = bucket.totalCount > 0 ? bucket.errorCount / bucket.totalCount : 0;
-
- return {
- route: bucket.route,
- method: bucket.method,
- totalCount: bucket.totalCount,
- errorCount: bucket.errorCount,
- errorRate,
- avgDurationMs,
- p95DurationMs,
- lastStatusCode: bucket.lastStatusCode,
- lastSeenAt: bucket.lastSeenAt,
- };
- });
-
- const totals = byRoute.reduce(
- (accumulator, current) => {
- accumulator.totalCount += current.totalCount;
- accumulator.errorCount += current.errorCount;
- return accumulator;
- },
- { totalCount: 0, errorCount: 0 }
- );
-
- return {
- service: "fortexa",
- timestamp: new Date().toISOString(),
- totals: {
- ...totals,
- errorRate: totals.totalCount > 0 ? totals.errorCount / totals.totalCount : 0,
- },
- routes: byRoute,
- };
-}
-
-export function renderHelpLine(name: string, helpText: string): string | null {
- if (!isAllowedMetricName(name)) {
- return null;
- }
- return `# HELP ${name} ${redactMetricText(helpText)}`;
-}
-
-export function renderTypeLine(name: string, type: "counter" | "gauge"): string | null {
- if (!isAllowedMetricName(name)) {
- return null;
- }
- return `# TYPE ${name} ${type}`;
-}
-
-export function renderMetricLine(
- name: string,
- labels: Record,
- value: string | number
-): string | null {
- if (!isAllowedMetricName(name)) {
- return null;
- }
- const keys = Object.keys(labels);
- for (const key of keys) {
- if (!isAllowedLabelKey(key)) {
- return null;
- }
- }
- const renderedLabels = keys
- .map((key) => `${key}="${escapePrometheusLabelValue(String(labels[key]))}"`)
- .join(",");
- return `${name}{${renderedLabels}} ${value}`;
-}
-
-export function toPrometheusText() {
- const snapshot = getMetricsSnapshot();
- const lines: string[] = [];
-
- const push = (...candidates: Array) => {
- for (const line of candidates) {
- if (line !== null) {
- lines.push(line);
- }
- }
- };
-
- push(renderHelpLine("fortexa_requests_total", "Total API requests by route/method"));
- push(renderTypeLine("fortexa_requests_total", "counter"));
- for (const route of snapshot.routes) {
- push(
- renderMetricLine("fortexa_requests_total", { route: route.route, method: route.method }, route.totalCount)
- );
- }
-
- push(renderHelpLine("fortexa_request_errors_total", "Total API errors by route/method"));
- push(renderTypeLine("fortexa_request_errors_total", "counter"));
- for (const route of snapshot.routes) {
- push(
- renderMetricLine("fortexa_request_errors_total", { route: route.route, method: route.method }, route.errorCount)
- );
- }
-
- push(renderHelpLine("fortexa_request_duration_ms_p95", "P95 request duration in milliseconds"));
- push(renderTypeLine("fortexa_request_duration_ms_p95", "gauge"));
- for (const route of snapshot.routes) {
- push(
- renderMetricLine(
- "fortexa_request_duration_ms_p95",
- { route: route.route, method: route.method },
- route.p95DurationMs.toFixed(2)
- )
- );
- }
-
- push(renderHelpLine("fortexa_decision_outcomes_total", "Total decision evaluations by outcome"));
- push(renderTypeLine("fortexa_decision_outcomes_total", "counter"));
- for (const [outcome, count] of decisionOutcomeCounts) {
- push(renderMetricLine("fortexa_decision_outcomes_total", { outcome }, count));
- }
-
- push(renderHelpLine("fortexa_stellar_submit_results_total", "Total Stellar submission attempts by result"));
- push(renderTypeLine("fortexa_stellar_submit_results_total", "counter"));
- for (const [result, count] of stellarSubmitResultCounts) {
- push(renderMetricLine("fortexa_stellar_submit_results_total", { result }, count));
- }
-
- return `${lines.join("\n")}\n`;
-}
-
-export function recordDecisionOutcome(outcome: DecisionOutcome) {
- // Only allow low-cardinality allowlisted outcomes; drop free-text/wallet injection
- const normalized = String(outcome).trim().toUpperCase();
- if (!ALLOWED_OUTCOMES.has(normalized as DecisionOutcome)) {
- return;
- }
- const safeOutcome = normalized as DecisionOutcome;
- decisionOutcomeCounts.set(safeOutcome, (decisionOutcomeCounts.get(safeOutcome) ?? 0) + 1);
-}
-
-export function recordStellarSubmitResult(result: StellarSubmitResult) {
- if (!ALLOWED_RESULTS.has(result as StellarSubmitResult)) {
- return;
- }
- stellarSubmitResultCounts.set(result as StellarSubmitResult, (stellarSubmitResultCounts.get(result as StellarSubmitResult) ?? 0) + 1);
-}
-
-export function resetMetrics() {
- buckets.clear();
- decisionOutcomeCounts.clear();
- stellarSubmitResultCounts.clear();
-}
-
-export function getDecisionOutcomeCounts(): ReadonlyMap {
- return decisionOutcomeCounts;
-}
-
-export function getStellarSubmitResultCounts(): ReadonlyMap {
- return stellarSubmitResultCounts;
-}
+type MetricKey = `${string}:${string}`;
+
+export type DecisionOutcome = "APPROVE" | "WARN" | "REQUIRE_APPROVAL" | "BLOCK";
+export type StellarSubmitResult =
+ | "success"
+ | "horizon_failure"
+ | "validation_failure"
+ | "idempotency_replay"
+ | "idempotency_conflict"
+ | "source_wallet_mismatch";
+
+export const ALLOWED_ROUTES = new Set([
+ "/api/auth/challenge",
+ "/api/auth/login",
+ "/api/auth/logout",
+ "/api/auth/refresh",
+ "/api/auth/session",
+ "/api/auth/wallet/revoke",
+ "/api/decision",
+ "/api/policy",
+ "/api/policy/history",
+ "/api/policy/validate",
+ "/api/policy/simulate",
+ "/api/policy/rollback",
+ "/api/policy/rollback/preview",
+ "/api/agent/plan",
+ "/api/health",
+ "/api/metrics",
+ "/api/stellar/balance",
+ "/api/stellar/build-payment",
+ "/api/stellar/fund",
+ "/api/stellar/pay",
+ "/api/stellar/setup",
+ "/api/stellar/submit-signed",
+ "/api/audit",
+ "/api/audit/export",
+ "/api/audit/integrity",
+ "/other",
+]);
+
+export const ALLOWED_METHODS = new Set([
+ "GET",
+ "POST",
+ "PUT",
+ "DELETE",
+ "PATCH",
+ "HEAD",
+ "OPTIONS",
+]);
+
+export const ALLOWED_OUTCOMES: ReadonlySet = new Set([
+ "APPROVE",
+ "WARN",
+ "REQUIRE_APPROVAL",
+ "BLOCK",
+]);
+
+export const ALLOWED_RESULTS: ReadonlySet = new Set([
+ "success",
+ "horizon_failure",
+ "validation_failure",
+ "idempotency_replay",
+ "idempotency_conflict",
+ "source_wallet_mismatch",
+]);
+
+/**
+ * Aggregated enforcement counters.
+ *
+ * `allow`/`deny` mirror how `POST /api/decision` treats an outcome: APPROVE and
+ * WARN let the action proceed (usage is consumed), REQUIRE_APPROVAL and BLOCK stop
+ * it. `rateLimit` counts requests the rate limiter rejected before any handler
+ * work ran, and `submitFailures` counts every signed-submission result that did
+ * not settle on-chain (`idempotency_replay` is a successful no-op, so it is not
+ * a failure).
+ *
+ * Both `getMetricsSnapshot()` consumers (the `/api/metrics` body and the ops
+ * dashboard loader) read this one object, so the screen and the scrape cannot
+ * disagree about these numbers.
+ */
+export type OpsCounters = {
+ allow: number;
+ deny: number;
+ rateLimit: number;
+ submitFailures: number;
+};
+
+export const ALLOW_OUTCOMES: readonly DecisionOutcome[] = ["APPROVE", "WARN"];
+export const DENY_OUTCOMES: readonly DecisionOutcome[] = ["REQUIRE_APPROVAL", "BLOCK"];
+export const SUBMIT_FAILURE_RESULTS: readonly StellarSubmitResult[] = [
+ "horizon_failure",
+ "validation_failure",
+ "idempotency_conflict",
+ "source_wallet_mismatch",
+];
+
+/**
+ * Maximum distinct route+method series before new series are dropped.
+ * This is a second-layer guard: primary guard is the allowlist which
+ * collapses arbitrary user-controlled values to "/other".
+ * Set to accommodate all legitimate routeΓmethod combinations (ALLOWED_ROUTES Γ ALLOWED_METHODS)
+ * while still bounding explosion if the allowlist is misconfigured.
+ */
+export const MAX_CARDINALITY = 200;
+
+const decisionOutcomeCounts = new Map();
+const stellarSubmitResultCounts = new Map();
+let rateLimitRejections = 0;
+
+type MetricBucket = {
+ route: string;
+ method: string;
+ totalCount: number;
+ errorCount: number;
+ totalDurationMs: number;
+ durationsMs: number[];
+ lastStatusCode: number;
+ lastSeenAt: string;
+};
+
+const buckets = new Map();
+const MAX_DURATIONS = 500;
+
+const WALLET_PATTERN = /G[A-Z2-7]{55}/;
+const FREE_TEXT_PATTERN = /[\s\n\r\t"'`]/;
+
+export function normalizeRoute(route: string): string {
+ if (!route || typeof route !== "string") {
+ return "/other";
+ }
+ // Strip query string and hash fragment β those can contain free-text / wallet data
+ let normalized = route.trim().split("?")[0].split("#")[0] ?? "";
+ // Remove trailing slash (except root)
+ if (normalized.length > 1 && normalized.endsWith("/")) {
+ normalized = normalized.slice(0, -1);
+ }
+ // Drop overly long values (likely free-text injection)
+ if (normalized.length === 0 || normalized.length > 128) {
+ return "/other";
+ }
+ // Drop values containing Stellar wallet addresses
+ if (WALLET_PATTERN.test(normalized)) {
+ return "/other";
+ }
+ // Drop values containing whitespace/quotes (free-text)
+ if (FREE_TEXT_PATTERN.test(normalized)) {
+ return "/other";
+ }
+ // Only allow explicitly listed routes; everything else collapses to /other
+ if (ALLOWED_ROUTES.has(normalized)) {
+ return normalized;
+ }
+ return "/other";
+}
+
+export function normalizeMethod(method: string): string {
+ if (!method || typeof method !== "string") {
+ return "UNKNOWN";
+ }
+ const upper = method.trim().toUpperCase();
+ if (ALLOWED_METHODS.has(upper)) {
+ return upper;
+ }
+ return "UNKNOWN";
+}
+
+export function escapePrometheusLabelValue(value: string): string {
+ return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
+}
+
+function keyOf(route: string, method: string): MetricKey {
+ return `${method.toUpperCase()}:${route}`;
+}
+
+function percentile(values: number[], p: number) {
+ if (values.length === 0) {
+ return 0;
+ }
+
+ const sorted = [...values].sort((left, right) => left - right);
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
+ return sorted[index] ?? 0;
+}
+
+export function recordApiMetric(input: {
+ route: string;
+ method: string;
+ statusCode: number;
+ durationMs: number;
+}) {
+ const sanitizedRoute = normalizeRoute(input.route);
+ const sanitizedMethod = normalizeMethod(input.method);
+ const key = keyOf(sanitizedRoute, sanitizedMethod);
+
+ // Cardinality guard: drop new series once MAX_CARDINALITY is reached
+ if (!buckets.has(key) && buckets.size >= MAX_CARDINALITY) {
+ return;
+ }
+
+ const current = buckets.get(key) ?? {
+ route: sanitizedRoute,
+ method: sanitizedMethod,
+ totalCount: 0,
+ errorCount: 0,
+ totalDurationMs: 0,
+ durationsMs: [],
+ lastStatusCode: 0,
+ lastSeenAt: new Date().toISOString(),
+ };
+
+ current.totalCount += 1;
+ current.totalDurationMs += input.durationMs;
+ current.lastStatusCode = input.statusCode;
+ current.lastSeenAt = new Date().toISOString();
+
+ if (input.statusCode >= 400) {
+ current.errorCount += 1;
+ }
+
+ current.durationsMs.push(input.durationMs);
+ if (current.durationsMs.length > MAX_DURATIONS) {
+ current.durationsMs.splice(0, current.durationsMs.length - MAX_DURATIONS);
+ }
+
+ buckets.set(key, current);
+}
+
+function sumCounter(counts: ReadonlyMap, keys: readonly TKey[]): number {
+ return keys.reduce((total, key) => total + (counts.get(key) ?? 0), 0);
+}
+
+/**
+ * Roll the low-cardinality counters up into the four numbers operators act on.
+ * Every field is derived from in-process counters only β never from a scan of
+ * the audit store β so it is safe to render on a dashboard and to alert on.
+ */
+export function getOpsCounters(): OpsCounters {
+ return {
+ allow: sumCounter(decisionOutcomeCounts, ALLOW_OUTCOMES),
+ deny: sumCounter(decisionOutcomeCounts, DENY_OUTCOMES),
+ rateLimit: rateLimitRejections,
+ submitFailures: sumCounter(stellarSubmitResultCounts, SUBMIT_FAILURE_RESULTS),
+ };
+}
+
+export type MetricsSnapshot = ReturnType;
+
+export function getMetricsSnapshot() {
+ const byRoute = Array.from(buckets.values()).map((bucket) => {
+ const avgDurationMs = bucket.totalCount > 0 ? bucket.totalDurationMs / bucket.totalCount : 0;
+ const p95DurationMs = percentile(bucket.durationsMs, 95);
+ const errorRate = bucket.totalCount > 0 ? bucket.errorCount / bucket.totalCount : 0;
+
+ return {
+ route: bucket.route,
+ method: bucket.method,
+ totalCount: bucket.totalCount,
+ errorCount: bucket.errorCount,
+ errorRate,
+ avgDurationMs,
+ p95DurationMs,
+ lastStatusCode: bucket.lastStatusCode,
+ lastSeenAt: bucket.lastSeenAt,
+ };
+ });
+
+ const totals = byRoute.reduce(
+ (accumulator, current) => {
+ accumulator.totalCount += current.totalCount;
+ accumulator.errorCount += current.errorCount;
+ return accumulator;
+ },
+ { totalCount: 0, errorCount: 0 }
+ );
+
+ return {
+ service: "fortexa",
+ timestamp: new Date().toISOString(),
+ totals: {
+ ...totals,
+ errorRate: totals.totalCount > 0 ? totals.errorCount / totals.totalCount : 0,
+ },
+ counters: getOpsCounters(),
+ routes: byRoute,
+ };
+}
+
+export function toPrometheusText() {
+ const snapshot = getMetricsSnapshot();
+ const lines: string[] = [];
+
+ lines.push("# HELP fortexa_requests_total Total API requests by route/method");
+ lines.push("# TYPE fortexa_requests_total counter");
+
+ for (const route of snapshot.routes) {
+ lines.push(
+ `fortexa_requests_total{route="${escapePrometheusLabelValue(route.route)}",method="${escapePrometheusLabelValue(route.method)}"} ${route.totalCount}`
+ );
+ }
+
+ lines.push("# HELP fortexa_request_errors_total Total API errors by route/method");
+ lines.push("# TYPE fortexa_request_errors_total counter");
+
+ for (const route of snapshot.routes) {
+ lines.push(
+ `fortexa_request_errors_total{route="${escapePrometheusLabelValue(route.route)}",method="${escapePrometheusLabelValue(route.method)}"} ${route.errorCount}`
+ );
+ }
+
+ lines.push("# HELP fortexa_request_duration_ms_p95 P95 request duration in milliseconds");
+ lines.push("# TYPE fortexa_request_duration_ms_p95 gauge");
+
+ for (const route of snapshot.routes) {
+ lines.push(
+ `fortexa_request_duration_ms_p95{route="${escapePrometheusLabelValue(route.route)}",method="${escapePrometheusLabelValue(route.method)}"} ${route.p95DurationMs.toFixed(2)}`
+ );
+ }
+
+ lines.push("# HELP fortexa_decision_outcomes_total Total decision evaluations by outcome");
+ lines.push("# TYPE fortexa_decision_outcomes_total counter");
+ for (const [outcome, count] of decisionOutcomeCounts) {
+ lines.push(`fortexa_decision_outcomes_total{outcome="${escapePrometheusLabelValue(outcome)}"} ${count}`);
+ }
+
+ lines.push("# HELP fortexa_stellar_submit_results_total Total Stellar submission attempts by result");
+ lines.push("# TYPE fortexa_stellar_submit_results_total counter");
+ for (const [result, count] of stellarSubmitResultCounts) {
+ lines.push(`fortexa_stellar_submit_results_total{result="${escapePrometheusLabelValue(result)}"} ${count}`);
+ }
+
+ // Rolled-up enforcement counters. These are always emitted (zero-initialised) so
+ // alerting rules and Grafana panels stay valid on a freshly started process, and
+ // so the scrape reports exactly the numbers the ops dashboard renders.
+ lines.push("# HELP fortexa_decisions_allowed_total Total decisions that were allowed to proceed");
+ lines.push("# TYPE fortexa_decisions_allowed_total counter");
+ lines.push(`fortexa_decisions_allowed_total ${snapshot.counters.allow}`);
+
+ lines.push("# HELP fortexa_decisions_denied_total Total decisions that stopped the action");
+ lines.push("# TYPE fortexa_decisions_denied_total counter");
+ lines.push(`fortexa_decisions_denied_total ${snapshot.counters.deny}`);
+
+ lines.push("# HELP fortexa_rate_limit_rejections_total Total requests rejected by the rate limiter");
+ lines.push("# TYPE fortexa_rate_limit_rejections_total counter");
+ lines.push(`fortexa_rate_limit_rejections_total ${snapshot.counters.rateLimit}`);
+
+ lines.push("# HELP fortexa_stellar_submit_failures_total Total Stellar submissions that did not settle on-chain");
+ lines.push("# TYPE fortexa_stellar_submit_failures_total counter");
+ lines.push(`fortexa_stellar_submit_failures_total ${snapshot.counters.submitFailures}`);
+
+ return `${lines.join("\n")}\n`;
+}
+
+export function recordDecisionOutcome(outcome: DecisionOutcome) {
+ // Only allow low-cardinality allowlisted outcomes; drop free-text/wallet injection
+ const normalized = String(outcome).trim().toUpperCase();
+ if (!ALLOWED_OUTCOMES.has(normalized as DecisionOutcome)) {
+ return;
+ }
+ const safeOutcome = normalized as DecisionOutcome;
+ decisionOutcomeCounts.set(safeOutcome, (decisionOutcomeCounts.get(safeOutcome) ?? 0) + 1);
+}
+
+export function recordStellarSubmitResult(result: StellarSubmitResult) {
+ if (!ALLOWED_RESULTS.has(result as StellarSubmitResult)) {
+ return;
+ }
+ stellarSubmitResultCounts.set(result as StellarSubmitResult, (stellarSubmitResultCounts.get(result as StellarSubmitResult) ?? 0) + 1);
+}
+
+export function resetMetrics() {
+ buckets.clear();
+ decisionOutcomeCounts.clear();
+ stellarSubmitResultCounts.clear();
+ rateLimitRejections = 0;
+}
+
+export function recordRateLimitRejection() {
+ rateLimitRejections += 1;
+}
+
+export function getRateLimitRejectionCount(): number {
+ return rateLimitRejections;
+}
+
+export function getDecisionOutcomeCounts(): ReadonlyMap {
+ return decisionOutcomeCounts;
+}
+
+export function getStellarSubmitResultCounts(): ReadonlyMap {
+ return stellarSubmitResultCounts;
+}
diff --git a/src/lib/readiness/production.ts b/src/lib/readiness/production.ts
index dc254909..9b30eaea 100644
--- a/src/lib/readiness/production.ts
+++ b/src/lib/readiness/production.ts
@@ -84,10 +84,15 @@ function addIssue(
issues.push({ setting, message, remediation });
}
-type ReadinessEnv = Record;
+/**
+ * Looser than `NodeJS.ProcessEnv` on purpose: Next 16 declares `NODE_ENV` as a
+ * required property, which would make it impossible to pass a partial env (as
+ * the readiness tests do) while simulating a production configuration.
+ */
+export type ReadinessEnv = Readonly>;
export function checkProductionReadiness(
- env: Record = process.env,
+ env: ReadinessEnv = process.env,
options: ProductionReadinessOptions = {}
): ProductionReadinessReport {
const cwd = options.cwd ?? process.cwd();
@@ -250,13 +255,13 @@ export function checkProductionReadiness(
}
export function shouldEnforceProductionReadiness(
- env: Record = process.env
+ env: ReadinessEnv = process.env
) {
return env.NODE_ENV === "production";
}
export function getProtectedPaymentFlowReadinessReport(
- env: Record = process.env,
+ env: ReadinessEnv = process.env,
options: ProductionReadinessOptions = {}
) {
if (!shouldEnforceProductionReadiness(env)) {
diff --git a/src/lib/security/analyzer.test.ts b/src/lib/security/analyzer.test.ts
index 91740753..a57c3b95 100644
--- a/src/lib/security/analyzer.test.ts
+++ b/src/lib/security/analyzer.test.ts
@@ -264,7 +264,9 @@ describe("evaluateSecurity", () => {
const result = await evaluateSecurity(makeAction());
- expect(result.analyzerStatus.blocklistStatus).toBe("timeout");
+ // Timeouts surface as an error status with the dedicated timeout flag
+ // (see commit "propagate blocklist fetch errors and fix secret-targeting regex").
+ expect(result.analyzerStatus.blocklistStatus).toBe("error");
expect(result.analyzerStatus.blocklistTimedOut).toBe(true);
expect(result.analyzerStatus.isDegraded).toBe(true);
});
diff --git a/src/lib/security/analyzer.ts b/src/lib/security/analyzer.ts
index 5f49f128..133de2c2 100644
--- a/src/lib/security/analyzer.ts
+++ b/src/lib/security/analyzer.ts
@@ -170,40 +170,55 @@ async function fetchBlocklistWithTimeout(): Promise<{
blocklist: string[];
status: BlocklistFetchStatus;
}> {
- if (!process.env.FORTEXA_BLOCKLIST_URL) {
- return { blocklist: [], status: { blocked: false, timedOut: false } };
- }
-
- const timeoutMs = getAnalyzerConfig().blocklistTimeoutMs;
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+ const { blocklistTimeoutMs } = getAnalyzerConfig();
try {
- const blocklist = await fetchBlocklist();
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), blocklistTimeoutMs);
+
+ try {
+ const blocklist = await fetchBlocklist();
+ clearTimeout(timeoutId);
+ const health = getBlocklistHealth();
+ if (health.lastError) {
+ return {
+ blocklist,
+ status: {
+ blocked: true,
+ timedOut: false,
+ error: health.lastError,
+ },
+ };
+ }
+ return { blocklist, status: { blocked: false, timedOut: false } };
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ } catch (err) {
+ const isTimeout = err instanceof Error && err.name === "AbortError";
+
+ // fetchBlocklist swallows errors internally, so check health for failures
const health = getBlocklistHealth();
- if (health.lastError) {
+ if (health.configured && health.lastError) {
+ const healthTimedOut = /abort|timeout/i.test(health.lastError);
return {
- blocklist,
- status: { blocked: true, timedOut: false, error: health.lastError },
+ blocklist: [],
+ status: {
+ blocked: true,
+ timedOut: healthTimedOut,
+ error: health.lastError,
+ },
};
}
- return { blocklist, status: { blocked: false, timedOut: false } };
- } catch (err) {
- const message = err instanceof Error ? err.message : "Unknown fetch error";
- const health = getBlocklistHealth();
- const timedOut =
- (err instanceof Error && err.name === "AbortError") ||
- /abort|timeout/i.test(health.lastError ?? message);
+
return {
blocklist: [],
status: {
blocked: true,
- timedOut,
- error: health.lastError ?? message,
+ timedOut: isTimeout,
+ error: err instanceof Error ? err.message : "Unknown error",
},
};
- } finally {
- clearTimeout(timeoutId);
}
}
diff --git a/src/lib/security/rate-limit.test.ts b/src/lib/security/rate-limit.test.ts
index 1a9cc487..84417b00 100644
--- a/src/lib/security/rate-limit.test.ts
+++ b/src/lib/security/rate-limit.test.ts
@@ -48,3 +48,43 @@ describe("rate limit shared state", () => {
expect(secondResult.retryAfterSeconds).toBeGreaterThan(0);
});
});
+
+describe("rate limit metrics", () => {
+ // Both modules are imported dynamically so they resolve from the same module
+ // registry; the shared-state test above calls `vi.resetModules()`.
+ async function loadModules() {
+ const rateLimit = await import("@/lib/security/rate-limit");
+ const metrics = await import("@/lib/observability/metrics");
+ await rateLimit.resetRateLimitStore();
+ metrics.resetMetrics();
+ return { rateLimit, metrics };
+ }
+
+ it("counts rejections so the ops dashboard and the metrics scrape agree", async () => {
+ const { rateLimit, metrics } = await loadModules();
+ const config = { key: "metrics-test", limit: 2, windowMs: 60_000 };
+
+ const first = await rateLimit.consumeRateLimit(requestFromIp("10.0.0.9"), config);
+ const second = await rateLimit.consumeRateLimit(requestFromIp("10.0.0.9"), config);
+ const rejected = await rateLimit.consumeRateLimit(requestFromIp("10.0.0.9"), config);
+
+ expect(first.ok).toBe(true);
+ expect(second.ok).toBe(true);
+ expect(rejected.ok).toBe(false);
+
+ expect(metrics.getRateLimitRejectionCount()).toBe(1);
+ expect(metrics.getMetricsSnapshot().counters.rateLimit).toBe(1);
+ });
+
+ it("leaves the counter at zero while the bucket still has capacity", async () => {
+ const { rateLimit, metrics } = await loadModules();
+
+ await rateLimit.consumeRateLimit(requestFromIp("10.0.0.10"), {
+ key: "metrics-test-no-rejection",
+ limit: 5,
+ windowMs: 60_000,
+ });
+
+ expect(metrics.getRateLimitRejectionCount()).toBe(0);
+ });
+});
diff --git a/src/lib/security/rate-limit.ts b/src/lib/security/rate-limit.ts
index a8ab4a4f..38ca8e2c 100644
--- a/src/lib/security/rate-limit.ts
+++ b/src/lib/security/rate-limit.ts
@@ -1,5 +1,6 @@
import type { NextRequest } from "next/server";
+import { recordRateLimitRejection } from "@/lib/observability/metrics";
import {
clearSharedRateLimits,
consumeSharedRateLimit,
@@ -91,6 +92,10 @@ export async function consumeRateLimit(request: NextRequest, config: BucketConfi
}
if (current.count >= config.limit) {
+ // Counted here (single choke point) so the ops dashboard and the metrics
+ // scrape report the same rejection count as the 429s actually returned.
+ recordRateLimitRejection();
+
return {
ok: false,
limit: config.limit,
diff --git a/src/lib/stellar/network.ts b/src/lib/stellar/network.ts
index e50dd67e..48d24976 100644
--- a/src/lib/stellar/network.ts
+++ b/src/lib/stellar/network.ts
@@ -8,10 +8,15 @@ function normalizeEnvValue(value: string | undefined) {
return trimmed && trimmed.length > 0 ? trimmed : null;
}
-type NetworkEnv = Record;
+/**
+ * Next 16 declares `NODE_ENV` as a required property of `NodeJS.ProcessEnv`,
+ * which would block callers from passing a partial env (readiness checks build
+ * one to simulate a production configuration).
+ */
+export type StellarEnv = Readonly>;
export function getStellarNetworkPassphrase(
- env: Record = process.env
+ env: StellarEnv = process.env
) {
return (
normalizeEnvValue(env.STELLAR_NETWORK_PASSPHRASE) ??
@@ -20,7 +25,7 @@ export function getStellarNetworkPassphrase(
}
export function getStellarHorizonUrl(
- env: Record = process.env
+ env: StellarEnv = process.env
) {
return (
normalizeEnvValue(env.STELLAR_HORIZON_URL) ??
diff --git a/src/lib/validation/errors.ts b/src/lib/validation/errors.ts
index e6969582..4339ff70 100644
--- a/src/lib/validation/errors.ts
+++ b/src/lib/validation/errors.ts
@@ -62,12 +62,9 @@ export function logValidationFailure(
error: ZodError,
rawBody?: unknown,
): void {
- logWarn(
- message,
- redactSensitiveFields({
- ...context,
- validation: error.flatten() as unknown as Record,
- body: rawBody,
- }) as unknown as LogContext,
- );
+ logWarn(message, redactSensitiveFields({
+ ...context,
+ validation: error.flatten(),
+ body: rawBody,
+ }));
}