Skip to content

Commit 0897c1d

Browse files
authored
Merge pull request #1610 from Abidoyesimze/feature/be-add-prometheus-alert-metrics-and-health-telemetry-to-database-row-level-security-service
feat(backend): add Prometheus alert metrics and health telemetry to Database Row-Level Security Service
2 parents 3724c7f + b1fba4d commit 0897c1d

7 files changed

Lines changed: 724 additions & 2 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Database Row-Level Security Service — Telemetry
2+
3+
Issue #1468. Every merchant-scoped transaction runs through `withMerchantContext()`
4+
(`src/lib/db-rls.js`), which sets `app.current_merchant_id` inside a transaction so
5+
Postgres Row-Level Security policies apply. This document covers the Prometheus
6+
metrics, health endpoint and alert rules added for that service.
7+
8+
## Metrics
9+
10+
Exported from `src/lib/db-rls-telemetry.js` on its own registry, merged into the
11+
main `/metrics` scrape by `src/routes/prometheus.js`. Label values are a fixed
12+
server-side set — never merchant ids, SQL text or error strings — so request
13+
traffic cannot inflate series cardinality or leak customer data into scrapes.
14+
15+
| Metric | Type | Labels | Meaning |
16+
| --- | --- | --- | --- |
17+
| `database_rls_transactions_total` | Counter | `outcome` | Transaction attempts by outcome |
18+
| `database_rls_transaction_duration_seconds` | Histogram | `outcome` | Wall time of the whole `withMerchantContext` call |
19+
| `database_rls_active_transactions` | Gauge | — | Transactions currently in flight |
20+
| `database_rls_health_state` | Gauge | — | `0` healthy, `1` degraded, `2` unhealthy (rolling window) |
21+
| `database_rls_error_ratio` | Gauge | — | Share of attempts that failed before completing |
22+
| `database_rls_rollback_ratio` | Gauge | — | Share of attempts rolled back by a failing callback |
23+
| `database_rls_last_transaction_timestamp_seconds` | Gauge | — | Unix time of the most recent attempt |
24+
25+
`outcome` values:
26+
27+
- `committed` — transaction committed successfully
28+
- `rolled_back` — the caller's callback threw; the transaction was rolled back
29+
- `invalid_input` — merchantId or callback failed validation; the pool was never touched
30+
- `connection_error` — `pool.connect()` failed (pool exhausted, DB down)
31+
- `transaction_error` — `BEGIN` / `SET LOCAL` / `COMMIT` failed, or `ROLLBACK` itself failed
32+
33+
## Health endpoint
34+
35+
`GET /health/database-row-level-security` returns the rolling-window snapshot:
36+
37+
```json
38+
{
39+
"status": "healthy",
40+
"reasons": [],
41+
"window_ms": 300000,
42+
"total": 1421,
43+
"committed": 1418,
44+
"rolled_back": 3,
45+
"invalid_input": 0,
46+
"connection_error": 0,
47+
"transaction_error": 0,
48+
"errors": 0,
49+
"rollback_ratio": 0.0021,
50+
"error_ratio": 0,
51+
"last_transaction_at": "2026-10-17T12:00:00.000Z",
52+
"thresholds": {
53+
"min_samples": 20,
54+
"error_ratio": 0.05,
55+
"rollback_ratio": 0.5,
56+
"invalid_input": 10
57+
}
58+
}
59+
```
60+
61+
Returns 503 only when `status` is `unhealthy`; `degraded` still returns 200. The
62+
same status is reported (informationally) under `services.database_row_level_security`
63+
in the main `GET /health` payload. The body contains no merchant ids, query text
64+
or error strings.
65+
66+
Status rules over the rolling window (default 5 minutes, 20-sample minimum):
67+
68+
- **unhealthy** — connection + transaction errors ≥ 5% of attempts
69+
- **degraded** — rolled-back callbacks ≥ 50% of attempts, or ≥ 10 `invalid_input` outcomes
70+
- **healthy** — everything else
71+
72+
Thresholds are overridable with `DATABASE_RLS_HEALTH_WINDOW_MS`,
73+
`DATABASE_RLS_HEALTH_MIN_SAMPLES`, `DATABASE_RLS_ERROR_RATIO_THRESHOLD`,
74+
`DATABASE_RLS_ROLLBACK_RATIO_THRESHOLD` and `DATABASE_RLS_INVALID_INPUT_THRESHOLD`.
75+
76+
## Alert rules
77+
78+
`docs/alerts/database-row-level-security.rules.yml` — load with
79+
`rule_files` and validate with
80+
`promtool check rules docs/alerts/database-row-level-security.rules.yml`:
81+
82+
- `DatabaseRlsUnhealthy` (critical) — health state ≥ 2 for 5m
83+
- `DatabaseRlsHighErrorRatio` (critical) — > 5% connection/transaction errors over 10m
84+
- `DatabaseRlsHighRollbackRatio` (warning) — > 50% callback rollbacks over 15m
85+
- `DatabaseRlsInvalidInputSpike` (warning) — > 10 invalid merchant ids in 10m (a caller is misbehaving)
86+
- `DatabaseRlsNoTraffic` (info) — no RLS transactions for 2h
87+
88+
## Notes on privacy
89+
90+
Telemetry records only aggregate counts, durations and derived ratios. Merchant
91+
ids are never used as label values or logged into the health snapshot, so the
92+
scrape and health outputs stay safe to expose to the same monitoring stack as the
93+
rest of the API.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Prometheus alerting rules for the Database Row-Level Security Service (issue #1468).
2+
#
3+
# Load with: rule_files: ["docs/alerts/database-row-level-security.rules.yml"]
4+
# Validate: promtool check rules docs/alerts/database-row-level-security.rules.yml
5+
#
6+
# Metric reference: docs/DATABASE_ROW_LEVEL_SECURITY.md
7+
8+
groups:
9+
- name: database-row-level-security
10+
rules:
11+
- alert: DatabaseRlsUnhealthy
12+
expr: max(database_rls_health_state) >= 2
13+
for: 5m
14+
labels:
15+
severity: critical
16+
annotations:
17+
summary: Database Row-Level Security service reports unhealthy
18+
description: >
19+
Rolling-window internal error ratio is above threshold —
20+
connections or transactions are failing before commit. See
21+
GET /health/database-row-level-security for counts and reasons.
22+
23+
- alert: DatabaseRlsHighErrorRatio
24+
expr: |
25+
(
26+
sum(rate(database_rls_transactions_total{outcome="connection_error"}[10m]))
27+
+ sum(rate(database_rls_transactions_total{outcome="transaction_error"}[10m]))
28+
)
29+
/
30+
clamp_min(sum(rate(database_rls_transactions_total[10m])), 1e-9)
31+
> 0.05
32+
and sum(increase(database_rls_transactions_total[10m])) >= 20
33+
for: 10m
34+
labels:
35+
severity: critical
36+
annotations:
37+
summary: More than 5% of RLS transactions fail before completing
38+
description: >
39+
Check logs for "Failed to acquire pooled connection" or
40+
"Failed to rollback transaction", and inspect Postgres
41+
connection limits and transaction timeouts.
42+
43+
- alert: DatabaseRlsHighRollbackRatio
44+
expr: |
45+
sum(rate(database_rls_transactions_total{outcome="rolled_back"}[15m]))
46+
/
47+
clamp_min(sum(rate(database_rls_transactions_total[15m])), 1e-9)
48+
> 0.5
49+
and sum(increase(database_rls_transactions_total[15m])) >= 20
50+
for: 15m
51+
labels:
52+
severity: warning
53+
annotations:
54+
summary: Majority of RLS transactions are rolled back by failing callbacks
55+
description: >
56+
withMerchantContext is rolling back most transactions, which usually
57+
means merchant-scoped queries are erroring (bad SQL, constraint
58+
violations, or validation inside the callback).
59+
60+
- alert: DatabaseRlsInvalidInputSpike
61+
expr: sum(increase(database_rls_transactions_total{outcome="invalid_input"}[10m])) > 10
62+
for: 10m
63+
labels:
64+
severity: warning
65+
annotations:
66+
summary: Spike of invalid merchant ids reaching the RLS service
67+
description: >
68+
Callers are passing merchant ids that fail sanitization/validation
69+
(empty, too long, or illegal characters). Find the offending caller
70+
in the logs ("Invalid merchantId provided to withMerchantContext").
71+
72+
- alert: DatabaseRlsNoTraffic
73+
expr: sum(increase(database_rls_transactions_total[1h])) == 0
74+
for: 2h
75+
labels:
76+
severity: info
77+
annotations:
78+
summary: No RLS transactions observed for two hours
79+
description: >
80+
The service may be unused in this environment, or the RLS path
81+
has stopped being exercised. Confirm with deployment state.

‎backend/src/app.js‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { versionDeprecationMiddleware } from "./lib/version-deprecation.js";
4949
import oracleRouter from "./routes/oracle.js";
5050
import { getPaymentSessionValidatorHealth } from "./lib/payment-session-validator.js";
5151
import { getExchangeRateOracleHealth } from "./lib/exchange-rate-oracle-telemetry.js";
52+
import { getDatabaseRowLevelSecurityHealth } from "./lib/db-rls-telemetry.js";
5253
import { configureExchangeRateCoordination } from "./services/exchangeRateService.js";
5354

5455
export async function createApp({ redisClient }) {
@@ -262,6 +263,7 @@ export async function createApp({ redisClient }) {
262263
// Informational only — does not affect `ok` / the status code.
263264
payment_session_validator: getPaymentSessionValidatorHealth().status,
264265
exchange_rate_oracle_cache: getExchangeRateOracleHealth().status,
266+
database_row_level_security: getDatabaseRowLevelSecurityHealth().status,
265267
},
266268
});
267269
});
@@ -314,6 +316,31 @@ export async function createApp({ redisClient }) {
314316
res.status(health.status === "unhealthy" ? 503 : 200).json(health);
315317
});
316318

319+
/**
320+
* @swagger
321+
* /health/database-row-level-security:
322+
* get:
323+
* summary: Database Row-Level Security Service health telemetry
324+
* description: >
325+
* Rolling-window counts, error / rollback ratios and derived status
326+
* for transactions executed through withMerchantContext (issue #1468).
327+
* Returns 503 only when the service is unhealthy (internal error ratio
328+
* above threshold); a degraded status (high rollback ratio or an
329+
* invalid-input spike) still returns 200. The body contains no
330+
* merchant ids, query text or error strings.
331+
* tags: [Health]
332+
* security: []
333+
* responses:
334+
* 200:
335+
* description: RLS service healthy or degraded
336+
* 503:
337+
* description: RLS service unhealthy
338+
*/
339+
app.get("/health/database-row-level-security", (_req, res) => {
340+
const health = getDatabaseRowLevelSecurityHealth();
341+
res.status(health.status === "unhealthy" ? 503 : 200).json(health);
342+
});
343+
317344
const verifyPaymentRateLimit = createVerifyPaymentRateLimit({
318345
store: redisAvailable ? createRedisRateLimitStore({ client: redisClient }) : undefined,
319346
});

0 commit comments

Comments
 (0)