Repository navigation
Ops dashboard reads the same counters the metrics route exports - #290
Merged
karagozemin merged 3 commits intoOct 1, 2026
Merged
karagozemin merged 3 commits into
karagozemin merged 3 commits into
Conversation
added 2 commits
September 30, 2026 08:57
`main` currently fails every CI job (lint, test, build). These are pre-existing defects from earlier bad merges, unrelated to the ops dashboard work that follows, fixed here so the branch is verifiable. Syntax errors that made the project uncompilable: - src/lib/auth/use-auth-session.ts: the `refresh` async helper was terminated with `}, []);` instead of `};`, so neither the compiler nor the bundler could parse the file. - src/lib/security/analyzer.ts: `fetchBlocklistWithTimeout` had an unclosed `catch` block with a duplicated `isTimeout` binding, and it referenced an out-of-scope `timeoutMs`. Restored the catch body to return a blocked status (health-based error first, then the thrown error) and source the timeout from `getAnalyzerConfig()`. Type errors surfaced once the files parsed: - logger.ts: export `LogContext` (validation/errors.ts imported a non-exported type) and widen its index signature to `unknown` so structured payloads such as a flattened Zod error can be logged. - validation/errors.ts: normalise flattened field errors with `Array.isArray` instead of `?? []`, which is a type error under Zod v4. - policy/rollback/preview/route.ts: log `rawBody` on validation failure; it referenced an undefined `bodyResult.data`. - readiness/production.ts + stellar/network.ts: accept a partial env (`ReadinessEnv` / `StellarEnv`) instead of `NodeJS.ProcessEnv`, which Next 16 now declares with a required `NODE_ENV`. - audit/export/route.test.ts: remove a duplicated `response` declaration left by a bad merge. - stellar/submit-signed/route.test.ts: type `importOriginal()` so the partial module mock can be spread. - tsconfig.json: target ES2020, required by the BigInt literals in src/lib/stellar/stroops.ts (issue Fortexa-stellar#178). Test/lint correctness: - analyzer.test.ts asserted `blocklistStatus === "timeout"`, but ea9da4f deliberately changed timeouts to report `"error"` plus the `blocklistTimedOut` flag. Assert the current contract. - metrics.test.ts: describe the `@ts-expect-error` directives, which `ban-ts-comment` rejects without a description.
The ops dashboard and the metrics route each counted decisions on their own, so an operator could see a different total than the one paging alerts use. One snapshot now feeds both views. - metrics.ts: add an `OpsCounters` block to `getMetricsSnapshot()` with `allow` (APPROVE + WARN), `deny` (REQUIRE_APPROVAL + BLOCK), `rateLimit` and `submitFailures`. Every field is rolled up from the existing in-process counters; nothing is derived from the audit store. - metrics.ts: export the same four numbers in the Prometheus body as `fortexa_decisions_allowed_total`, `fortexa_decisions_denied_total`, `fortexa_rate_limit_rejections_total` and `fortexa_stellar_submit_failures_total`, always emitted and zero-initialised so alert rules survive a restart. - rate-limit.ts: count rejections at the single choke point in `consumeRateLimit`, so the counter matches the 429s actually returned. - ops-dashboard.tsx: accept the snapshot as `initialMetrics`, seed state from it and render the four counters. An empty snapshot renders zeroes rather than a spinner, and a payload without `counters` degrades to zeroes instead of throwing. - settings/page.tsx: the Ops tab loader reads `getMetricsSnapshot()` in process and hands it to the dashboard, so first paint shows real numbers before any client fetch. Tests: new ops-dashboard.snapshot.test.tsx renders from a snapshot and asserts parity with the `/api/metrics` body without performing any HTTP; new settings/page.test.tsx covers the loader wiring; metrics, route and rate-limit suites cover the roll-ups, the Prometheus export and the zeroed empty snapshot. Docs: observability.md (metric table, JSON shape, PromQL, dashboard section), README.md, ARCHITECTURE.md and the reviewer evidence matrix.
|
@Aycode01 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ops dashboard reads the same counters the metrics route exports
Closes #227
Summary
The ops dashboard and the metrics route each counted decisions independently, so an operator could look at
/opsand see a different total from the one paging alerts use. This PR makes one in-process metrics snapshot feed both views.getMetricsSnapshot()now exposes acountersblock (allow,deny,rateLimit,submitFailures).src/app/settings/page.tsx, reached via/ops) readsgetMetricsSnapshot()on the server and hands it tosrc/components/ops-dashboard.tsx; the dashboard renders those counters.How it works
1. One snapshot, two consumers —
src/lib/observability/metrics.tsgetOpsCounters()rolls the existing low-cardinality counters up into the four numbers operators act on:allowAPPROVE+WARNPOST /api/decisionlets through (it consumes usage for both).denyREQUIRE_APPROVAL+BLOCKrateLimitsubmitFailureshorizon_failure,validation_failure,idempotency_conflict,source_wallet_mismatchsuccessandidempotency_replayare excluded — a replay is a successful no-op.The grouping lives in exported constants (
ALLOW_OUTCOMES,DENY_OUTCOMES,SUBMIT_FAILURE_RESULTS) so the roll-up is defined in exactly one place and is asserted against the existing allowlists in tests.snapshot.countersis now part of theMetricsSnapshottype, which is whatGET /api/metricsserialises and what the dashboard renders — the two cannot drift because they are the same object.2. The scrape exports the same numbers —
toPrometheusText()These are always emitted (even at zero) so alert rules and Grafana panels do not break on a freshly restarted process. The
fortexa_decision_outcomes_total{outcome=…}andfortexa_stellar_submit_results_total{result=…}families remain as the per-label source data; the four counters are derived roll-ups of them.3. Rate-limit rejections are counted at the choke point —
src/lib/security/rate-limit.tsconsumeRateLimit()records the rejection in the branch that returnsok: false, i.e. exactly where the 429 is produced. No route handler needed changing, and the counter cannot drift from the number of 429s a caller actually receives. It is reset byresetMetrics()alongside the other counters.4. The dashboard renders the snapshot —
src/components/ops-dashboard.tsxinitialMetrics?: MetricsSnapshotand seeds itsmetricsstate from it, so the counters (and totals/routes) are correct on first paint, before any client fetch.GET /api/metrics).readCounters()falls back to a zeroedEMPTY_COUNTERSwhen there is no snapshot, or when a payload predates thecountersblock (rolling deploys), so a tile can never renderLoadingand never throws./api/metricsis unchanged: it keeps route/traffic data moving, and because the route returns the same snapshot shape the counters simply refresh.The signed-TX card still reads the audit export — that is a different number and is unrelated to these four totals.
Tests
src/components/ops-dashboard.snapshot.test.tsx(new)fetchcalls; asserts the rendered numbers equalsnapshot.countersand equal the bodyGET /api/metricsexports (called in-process, no HTTP); empty snapshot → zeroes with no spinner in the counter section; pre-snapshot render → zeroes; mutating the in-process counters changes both the dashboard and the route body; counters are not attributable to the audit export.src/app/settings/page.test.tsx(new)src/lib/observability/metrics.test.tsresetMetrics()clears the new counter.src/app/api/metrics/route.test.tscounters; Prometheus export equals the JSON body; zeroes before any activity.src/lib/security/rate-limit.test.tssrc/components/ops-dashboard.test.tsDocumentation
docs/observability.md: new metrics rows,countersin the JSON shape, PromQL for decision throughput / denied share / rate-limit rejections / submit failures, a "One snapshot, two views" subsection explaining the loader and the zero-rendering rule, and a caveat that the in-app dashboard is per-instance.README.md(§13),ARCHITECTURE.md(§8),docs/REVIEWER_EVIDENCE_MATRIX.md: ops dashboard counter list and the loader/evidence pointers.npm run check:docspasses (88 links, 0 broken).Acceptance criteria
ops-dashboard.snapshot.test.tsx("renders the counters carried by the snapshot…", "renders the same counters the metrics route exports").fetchwas never called;/api/metricsis only invoked in-process to compare payloads.0to1.CI pre-checks (run locally, from a clean
.next)npm run lintnpm run testnpm run buildnpx tsc --noEmitnpm run check:docsmainredThis branch would never have gone green on its own:
maincurrently fails all three CI jobs. These defects come from earlier bad merges and are unrelated to the ops dashboard, so they are isolated in the first commit (fix(ci): repair pre-existing breakage blocking build, tests and lint) and can be reviewed/cherry-picked separately.Syntax errors (nothing compiled):
src/lib/auth/use-auth-session.ts:101— therefreshasync helper ended with}, []);instead of};. Neithertscnor the bundler could parse the file;npm run buildfailed outright.src/lib/security/analyzer.ts—fetchBlocklistWithTimeouthad an unclosedcatchwith a duplicatedisTimeoutbinding and referenced an out-of-scopetimeoutMs. Restored acatchthat returns a blocked status (health-reported error first, then the thrown error) and sourced the timeout fromgetAnalyzerConfig().Type errors (surfaced once the files parsed):
src/lib/observability/logger.ts— exportLogContext(validation/errors.tsimported a non-exported type) and widen its index signature tounknownso a flattened Zod error can be logged.src/lib/validation/errors.ts— normalise flattened field errors withArray.isArray(?? []is a type error on Zod v4'sflatten()).src/app/api/policy/rollback/preview/route.ts— lograwBodyon validation failure; it referenced an undefinedbodyResult.data.src/lib/readiness/production.ts,src/lib/stellar/network.ts— accept a partial env (ReadinessEnv/StellarEnv); Next 16 declaresNodeJS.ProcessEnvwith a requiredNODE_ENV, which made every partial-env call site a type error.src/app/api/audit/export/route.test.ts— removed a duplicatedresponsedeclaration left by a bad merge.src/app/api/stellar/submit-signed/route.test.ts— typedimportOriginal()so the partial mock can be spread.tsconfig.json—targetES2017 → ES2020, required by the BigInt literals insrc/lib/stellar/stroops.ts(issue [SCF high][easy] Parse policy limits without floating-point drift #178).Tests/lint correctness:
analyzer.test.tsassertedblocklistStatus === "timeout", but commitea9da4fdeliberately changed timeouts to report"error"with theblocklistTimedOutflag. The test now asserts the current contract (it had never run, because the file did not parse).metrics.test.ts—@ts-expect-errordirectives now carry descriptions, asban-ts-commentrequires.One existing test assertion was deliberately narrowed:
route.test.tsasserted that everyfortexa_*line carriesroute/methodlabels. That is only true while every exported series is a route bucket; it now checks the three request-bucket families explicitly (the new roll-up counters are intentionally label-free).Reviewer notes
/api/metricsgains acountersobject — additive.docs/observability.md: the dashboard reads the snapshot of the instance that served the page, so in a multi-replica deployment it shows one replica. Prometheus remains the fleet-wide source of truth — this is pre-existing behaviour for the totals/routes too, not something this PR changes.