Cost Observability
Per-transaction connector cost
Cost Observability is the surface that answers “what did it actually cost us to move this money?” — interchange, scheme, processor, and acquirer fees broken down per connector and currency, plus interchange downgrades, cost anomalies, and a forward-looking fee forecast.
It is a thin read-only proxy over Hyperswitch’s analytics OLAP surface
(Kafka + ClickHouse + a scheduler), in keeping with
ADR-007 · Maximum native Hyperswitch bias.
MVS owns no cost data of its own here: every metric is computed in ClickHouse by
Hyperswitch and surfaced through /analytics/v1/cost/*. The route layer adds
exactly one thing on top — graceful degradation.
The single most important fact about this surface: it does almost nothing
useful unless the OLAP profile is provisioned. Hyperswitch’s analytics
pipeline (Kafka + ClickHouse + scheduler) is gated behind olap.enabled in the
Helm values and is off by default in any freshly built environment. When
it is off, every endpoint here returns a deep-link to the Control Center, and
the operator dashboard renders an empty state. See
OLAP dependency & degraded mode and the
Hyperswitch OLAP runbook.
Where this lives
The routes are mounted under the authenticated API surface in
apps/mvs-pay-extensions/src/app.ts:
so the public base path is /v1/cost-observability. Like every route on the
api sub-app it sits behind authMiddleware() (internal secret + tenant
resolution) and auditLog(). It is not rate-limited and not wrapped in
idempotency — both are correct for read-only GETs. The spec reference for the
surface is docs/superpowers/specs/2026-05-28-mvs-pay-perfection/14-phase-4-power-features.md
§ M22.2.
This is an internal, service-to-service surface. Every request requires the
x-internal-secret header plus a tenant (x-org-id or x-org-slug); see
Authentication & Tenancy and
ADR-010 · Internal secret / deferred auth.
The browser never calls these endpoints directly — the operator dashboard’s
Next.js route handlers proxy through the SDK, which injects the headers. See the
Merchant & Operator UI surface page.
The four endpoints
Four GET endpoints, each a best-effort proxy to one Hyperswitch analytics OLAP query. See the API Reference (tag: Cost Observability) for the full request/response schemas.
Query parameters
The first three endpoints share a date-range contract, assembled by
rangeQuery() in the route handler:
forecast does not take a range — it takes horizon, which is validated
against the literal set {"30d", "90d"}; anything else is
400 INVALID_HORIZON.
The optional narrowing params are accepted by every endpoint at the API edge
(the OpenAPI fragment declares profileId / connector / currency on
fee-breakdown, downgrades, and anomalies) but the client does not forward
all of them to every upstream query. Reading
hyperswitch-client.ts analytics:
feeBreakdownforwardsprofile_id,connector,currency.downgradesforwards onlyprofile_id(dropsconnector/currency).anomaliesforwards onlyfrom/to(drops all three narrowing params).forecastforwards onlyhorizon.
So passing connector=stripe to /downgrades or /anomalies is silently
ignored, not an error. If you need server-side filtering there, it has to be
added to the client method, not just the route.
Response shape: a discriminated union
Every endpoint returns one of two shapes. This is the crux of the surface:
The caller (SDK / operator dashboard) must branch on which key is present. The
OpenAPI fragment models this explicitly as a oneOf of
CostObservability<Metric>Data and CostObservabilityDashboardLink.
The OLAP data rows are typed in the OpenAPI fragment (the client itself only
types them as { data: unknown[] }):
- fee-breakdown —
connector,currency,interchange_amount,scheme_amount,processor_amount,acquirer_amount,total_fees,transaction_count,volume. - downgrades —
payment_id,connector,expected_tier,actual_tier,fee_delta,currency,captured_at. - anomalies —
connector,scheme,region,metric(fee_rate|volume|decline_rate),delta_pct,detected_at. - forecast —
date,expected_fees,currency,confidence_low,confidence_high.
OLAP dependency & degraded mode
This surface is only as good as the analytics pipeline behind it. That pipeline is the OLAP profile documented in the Hyperswitch OLAP runbook: three moving parts that must all be healthy before a single cost row exists.
If any link in that chain is missing — most commonly because olap.enabled is
false and the StatefulSets were never created — the analytics service returns
404/501 (or 503), and the route degrades.
How degradation works
Two small helpers in cost-observability.ts do all the work:
The per-endpoint control flow is identical:
Validate input
fee-breakdown / downgrades / anomalies require from + to
(400 MISSING_RANGE otherwise). forecast requires
horizon ∈ {30d, 90d} (400 INVALID_HORIZON otherwise). Validation runs
before any upstream call.
Best-effort call upstream
Call the matching hyperswitch.analytics.* method. On success, return its
{ data } payload verbatim.
The fallback is only triggered by 404 and 501. A 503 from the
analytics service — which the OLAP runbook calls out as “ClickHouse isn’t
reachable from the analytics service” — is not fallback-eligible and will
surface to the caller as a 503. That is intentional (a 503 is a transient
fault worth retrying / paging on, not a “feature is off” signal), but it means
“OLAP is degraded” and “OLAP is disabled” produce different caller-visible
behaviour. Know which one you’re looking at.
The deep-link target
The fallback URL is built from DASHBOARD_BASE_URL:
So the link is ${HYPERSWITCH_DASHBOARD_URL || default}/<metric>, where
<metric> is fee-breakdown, downgrades, anomalies, or forecast. The
path segment deliberately mirrors the endpoint name so an operator lands on the
right Control Center view. The same data is rendered there via the embedded
Control Center analytics page — which itself only has data if OLAP is on, so a
deep-link from a disabled environment leads to an empty Control Center chart,
not a populated one. The link is an escape hatch, not a fix.
What the operator sees
When the route returns { link_to_dashboard }, the operator dashboard at
apps/mvs-pay/app/(dashboard)/cost-observability/ renders an empty state
rather than charts. (Note this page ships on disk but is not on the navigation
rail returned by getMvsPayNavSections() — it is reached contextually; see the
Merchant & Operator UI page.)
Forecast is the honest stub
Be explicit about this one, because it is the most likely to mislead:
Hyperswitch v1.123.1 does not surface its forecast model via the OLAP API.
The OpenAPI fragment says so plainly: the forecast endpoint “typically
degrades to a link_to_dashboard deep-link.” In practice
/v1/cost-observability/forecast returns a deep-link in essentially every
environment today, OLAP-on or OLAP-off. fee-breakdown / downgrades /
anomalies will return real { data } once OLAP is provisioned and the
upstream metric exists; forecast is a placeholder that returns a link until
Hyperswitch ships the metric (or MVS computes it from ClickHouse itself, which
is not done).
The endpoint still validates horizon strictly so the contract is honest
about what it accepts even while the data isn’t there.
Worked examples
Happy path (OLAP provisioned, data exists):
Degraded path (OLAP not provisioned → 404 upstream → deep-link):
Validation error (missing range):
These three shapes — data, link_to_dashboard, and the error envelope — are
exactly what the tests in cost-observability.test.ts assert.
Turning the data on
If the dashboard is showing empty states and you need real numbers, the fix is not in this route — it’s provisioning OLAP. The full procedure is the Hyperswitch OLAP runbook; the short version:
Confirm the toggle
OLAP is gated by a single key, olap.enabled, in
deploy/hyperswitch/values-{prod,staging}.yaml. The values blocks already
exist in the repo; in an environment where it was off you usually don’t edit
anything, you just verify and sync.
Sync via Argo CD
argocd app sync hyperswitch-prod (optionally targeting the Kafka /
ClickHouse StatefulSets and scheduler Deployment to limit blast radius)
reconciles the new resources.
The OLAP profile has real hardware cost — prod allocates roughly 250 Gi of new EBS plus a few Gi of pod memory across Kafka (3 brokers), ClickHouse (single replica, do not run it under a 4 Gi memory limit), and the scheduler. There is no PITR on Kafka (replication factor 3 covers it) and ClickHouse PITR is EBS-snapshot based with a target RPO of ~1 hour. Total ClickHouse data loss is only recoverable up to Kafka’s retention horizon (default 7 days). Read the runbook’s sizing and disaster-recovery sections before turning it on in a new environment.
Gaps & gotchas for the next owner
The surface is honest, but quiet
Three of the four endpoints degrade silently — only fee-breakdown logs an
info line when it falls back to a deep-link. If you’re debugging “why is the
cost dashboard empty,” don’t expect a log trail from downgrades / anomalies
/ forecast. The signal that OLAP is off is the { link_to_dashboard } shape
in the response, not the logs.
404/501 means two different things, conflated
isFallbackEligible treats 404 and 501 identically. Upstream, 404 tends
to mean “OLAP disabled / namespace returns nothing” and 501 means “metric not
implemented for this chart version.” Both collapse into the same deep-link, so
the route cannot tell you which it was. If you need to distinguish “the
pipeline is off” from “Hyperswitch never built this metric,” you have to look at
the upstream status directly (e.g. via the analytics service logs), not the
route’s response.
Narrowing params are accepted but not always forwarded
As noted above, connector / currency are documented on downgrades and
anomalies in the OpenAPI fragment but dropped by the client. This is a real
mismatch between the published contract and behaviour — a consumer filtering
client-side will get correct (if unfiltered) data; a consumer expecting
server-side filtering will be surprised. Closing this means extending the
analytics.downgrades / analytics.anomalies query mappings in
hyperswitch-client.ts.
Forecast may never return data without MVS work
forecast is gated on Hyperswitch shipping a forecast metric over OLAP, which
v1.123.1 does not. Treat any plan that depends on forecast returning { data }
as blocked until either (a) a Hyperswitch upgrade exposes it (see the
Hyperswitch Upgrade runbook) or
(b) MVS computes the forecast from ClickHouse directly — neither of which
exists today.