Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
406dc05
Made a little harmless changes
Aboyeji-Isaac Oct 20, 2026
872a2f6
feat(metrics): add Prometheus metrics collector
Aboyeji-Isaac Oct 27, 2026
eea14ab
feat(metrics): expose read-only indexer lag snapshot
Aboyeji-Isaac Oct 27, 2026
4fe40f5
feat(metrics): record HTTP request metrics
Aboyeji-Isaac Oct 27, 2026
a4777f2
feat(metrics): add Prometheus metrics controller
Aboyeji-Isaac Oct 27, 2026
a601585
feat(metrics): protect Prometheus metrics route
Aboyeji-Isaac Oct 27, 2026
8ece1e5
feat(metrics): register versioned metrics route
Aboyeji-Isaac Oct 27, 2026
5a8ff22
feat(metrics): add validated metrics feature flag
Aboyeji-Isaac Oct 27, 2026
ae50e03
docs(metrics): document ENABLE_METRICS configuration
Aboyeji-Isaac Oct 27, 2026
44334bc
feat(metrics): mount protected Prometheus endpoint
Aboyeji-Isaac Oct 27, 2026
5e4e9d5
docs(metrics): document Prometheus endpoint and alert guidance
Aboyeji-Isaac Oct 27, 2026
828f267
test(metrics): cover Prometheus exposition and collectors
Aboyeji-Isaac Oct 27, 2026
b86e611
fix(metrics): handle unavailable MongoDB pool stats
Aboyeji-Isaac Oct 27, 2026
66a7f8d
docs(metrics): link Prometheus operations guide from README
Aboyeji-Isaac Oct 27, 2026
5381c12
chore: remove placeholder PR draft from metrics change
Aboyeji-Isaac Oct 27, 2026
472045a
test(metrics): correct escaped label assertion
Aboyeji-Isaac Oct 27, 2026
8c03472
fix(metrics): bound Redis probe and report pool visibility accurately
Aboyeji-Isaac Oct 27, 2026
43e1ebb
test(metrics): verify route labels avoid request identifiers
Aboyeji-Isaac Oct 27, 2026
b38a374
docs(metrics): add deployment guidance for protected metrics endpoint
Aboyeji-Isaac Oct 27, 2026
dfcbc63
style(metrics): format metrics environment schema
Aboyeji-Isaac Oct 27, 2026
8c334d8
style(metrics): format Prometheus collector output
Aboyeji-Isaac Oct 27, 2026
1cf795b
style(metrics): wrap long Prometheus assertion
Aboyeji-Isaac Oct 27, 2026
f832986
chore: preserve repository commit message template
Aboyeji-Isaac Oct 27, 2026
a107995
fix(metrics): declare cumulative Socket.IO metrics as counters
Aboyeji-Isaac Oct 27, 2026
313569a
fix(metrics): exclude metrics route variants from request counters
Aboyeji-Isaac Oct 27, 2026
303897d
test(metrics): assert cumulative socket metrics use counter type
Aboyeji-Isaac Oct 27, 2026
b2580ed
fix(metrics): handle degraded RPC responses in lag snapshot
Aboyeji-Isaac Oct 27, 2026
7a3e516
fix(metrics): mark degraded RPC lag snapshots unavailable
Aboyeji-Isaac Oct 27, 2026
c15d7dc
style(metrics): format Prometheus metrics service
Aboyeji-Isaac Oct 27, 2026
4e1427b
style(metrics): format request metrics middleware
Aboyeji-Isaac Oct 27, 2026
b422d6b
style(config): format indexer lag threshold
Aboyeji-Isaac Oct 27, 2026
d4c57b2
style(metrics): use Prometheus counter naming convention
Aboyeji-Isaac Oct 27, 2026
aa706b2
test(metrics): align counter assertions with metric names
Aboyeji-Isaac Oct 27, 2026
345785e
docs(metrics): clarify Socket.IO metric types
Aboyeji-Isaac Oct 27, 2026
43555dd
docs(metrics): clarify per-process metric lifecycle
Aboyeji-Isaac Oct 27, 2026
3942b31
test(metrics): cover dependency failure and pool telemetry paths
Aboyeji-Isaac Oct 27, 2026
8ef9a9c
test(metrics): assert unavailable lag omits sample line
Aboyeji-Isaac Oct 27, 2026
7ad871d
Merge branch 'main' into fix/42-login-validation
Aboyeji-Isaac Oct 27, 2026
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ RATE_LIMIT_WINDOW_MS=900000
# Maximum requests per client per window before a 429 is returned. [default: 100]
RATE_LIMIT_MAX_REQUESTS=100

## Prometheus metrics endpoint (disabled by default; admin JWT required)
ENABLE_METRICS=false

# Outbound webhook that receives dispute lifecycle notifications. Empty string
# (the default) disables notifications. [default: "" — set to enable]
# NOTE: declared in the schema but not yet read by any service; wire it up in
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,3 +376,8 @@ linked PR 5,4,6
## 📄 License

This project is licensed under the ISC License.


## Prometheus metrics endpoint

Operational metrics are available at `GET /metrics` and `GET /api/v1/metrics` when `ENABLE_METRICS=true`. The endpoint is disabled by default and requires a valid admin Bearer token when enabled. See [Prometheus metrics and operations guidance](docs/prometheus-metrics.md) for metric names, scrape setup, and alert recommendations.
23 changes: 23 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Deployment notes

## Prometheus metrics endpoint

The operational metrics endpoint is disabled by default. To enable it in a deployment environment, set the validated variable in the service environment:

ENABLE_METRICS=true

Restart the application after changing this setting. No database migration is required.

When enabled, the protected endpoint is available at GET /metrics and GET /api/v1/metrics. Both require a valid Bearer JWT for a user with the admin role. Use HTTPS and restrict the endpoint to monitoring infrastructure through a private network, firewall, or trusted authentication proxy. Do not place long-lived admin credentials in source control or commit scrape tokens to a Prometheus configuration file.

A Prometheus-compatible scraper must send an Authorization: Bearer header. Use a secret file or a trusted proxy that can securely rotate credentials. If your deployment cannot supply rotating Bearer tokens safely, keep ENABLE_METRICS=false until an appropriate authentication boundary is configured.

For exported metric names, scrape configuration guidance, and suggested alerts, see [Prometheus metrics](prometheus-metrics.md).

## Operational safety

- Confirm the endpoint returns 404 while ENABLE_METRICS is false.
- Confirm a request without a valid admin token receives an authentication error when metrics are enabled.
- Confirm an authenticated non-admin receives 403.
- Confirm a valid admin token receives Prometheus text exposition with a text/plain content type.
- Monitor scrape latency: the indexer lag collector reads the persisted checkpoint and requests the current network ledger, so scrapes may be slower when the Stellar RPC service is degraded.
62 changes: 62 additions & 0 deletions docs/prometheus-metrics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Prometheus metrics

SwiftChain Backend exposes a Prometheus text exposition endpoint for operational monitoring.

## Enable and access

Set this validated environment variable in .env:

ENABLE_METRICS=true

The endpoint is disabled by default. When disabled, both paths return 404:

- GET /metrics
- GET /api/v1/metrics

When enabled, both require a valid Bearer JWT for a user with the admin role. Keep the endpoint behind TLS and an operator-only network boundary; do not expose admin credentials in Prometheus scrape configuration. Use a dedicated monitoring identity and rotate its token according to your security policy.

Example request:

curl -H "Authorization: Bearer $METRICS_ADMIN_TOKEN" http://localhost:3000/metrics

The response uses text/plain; version=0.0.4; charset=utf-8 and can be scraped by Prometheus-compatible collectors.

## Exported metrics

| Metric | Type | Meaning |
| --- | --- | --- |
| swiftchain_http_requests_total | Counter | Completed HTTP requests, labelled by method, Express route template, and status code |
| swiftchain_http_request_duration_seconds | Histogram | Request duration buckets, sum, and count |
| swiftchain_process_uptime_seconds | Gauge | Process uptime |
| swiftchain_process_resident_memory_bytes | Gauge | Resident memory |
| swiftchain_process_heap_used_bytes / swiftchain_process_heap_total_bytes | Gauge | Node.js heap usage |
| swiftchain_mongodb_connection_ready | Gauge | Whether Mongoose is connected |
| swiftchain_mongodb_pool_stats_available | Gauge | Whether the installed MongoDB driver exposes pool statistics |
| swiftchain_mongodb_pool_connections | Gauge | Total, available, and in-use MongoDB pool connections |
| swiftchain_redis_healthy | Gauge | Result of a Redis PING check |
| swiftchain_circuit_breaker_open | Gauge | Circuit-breaker state by registered breaker |
| swiftchain_indexer_lag_available | Gauge | Whether a lag snapshot could be read |
| swiftchain_indexer_lag_ledgers | Gauge | Gap between persisted processed ledger and the live network ledger |
| swiftchain_socket_* | Gauge / counter | Current connection and latency snapshots are gauges; cumulative connection, disconnection, and message totals are counters |

HTTP route labels use Express route templates rather than raw request URLs, preventing user IDs, arbitrary paths, and query values from creating high-cardinality labels. The indexer metric reads the current checkpoint and live ledger without persisting alerts or sending webhooks during a scrape.

MongoDB pool metrics depend on runtime pool fields exposed by the installed MongoDB driver. Check swiftchain_mongodb_pool_stats_available before relying on pool connection values.

## Suggested scrape configuration

Configure your Prometheus-compatible scraper to use the application URL, HTTPS in production, and an authorization mechanism that securely supplies a short-lived admin token. Do not commit tokens or other credentials to prometheus.yml. If your scraper cannot safely rotate a Bearer token, place a trusted authentication proxy in front of the endpoint and restrict direct network access.

## Initial alert guidance

Treat these as starting points and tune them to the service's normal traffic:

- HTTP 5xx ratio remains above 5% for 5 minutes.
- Request p95 latency exceeds the service's agreed SLO for 5 minutes.
- swiftchain_mongodb_connection_ready is 0.
- swiftchain_redis_healthy is 0.
- Any swiftchain_circuit_breaker_open sample is 1.
- swiftchain_indexer_lag_ledgers remains above INDEXER_LAG_ALERT_THRESHOLD.
- MongoDB pool in-use connections remain close to total connections while request latency rises.

All in-memory request counters and socket totals are process-local and reset when that instance restarts. Scrape each instance separately and aggregate in the monitoring backend; do not treat one instance's values as a fleet-wide total. This endpoint exports metrics; it does not configure Prometheus, Grafana, Datadog, alert delivery, or retention automatically.
6 changes: 6 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import rateLimit from 'express-rate-limit';
import swaggerUi from 'swagger-ui-express';

import routes from './routes';
import metricsRoutes from './routes/metricsRoutes';
import prometheusMetricsMiddleware from './middleware/prometheusMetricsMiddleware';
import logger from './config/logger';
import { sendError } from './utils/responseWrapper';
import { connectDatabase } from './config/database';
Expand Down Expand Up @@ -37,6 +39,7 @@ app.use(compression());
// Track in-flight requests and reject new ones during graceful shutdown.
app.use(requestTracker);
app.use(requestLogger);
app.use(prometheusMetricsMiddleware);

// Swagger UI needs inline <script>/<style>, which the default Helmet CSP
// blocks, so it gets its own relaxed CSP scoped to /api-docs only — the
Expand Down Expand Up @@ -92,6 +95,9 @@ app.get('/health', (_req, res): void => {
});
});

// Prometheus scrape path follows the conventional /metrics URL; the same
// protected endpoint is also available at /api/v1/metrics.
app.use('/metrics', metricsRoutes);
app.use('/api', routes);

app.use((req, res): void => {
Expand Down
16 changes: 14 additions & 2 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
CORS_ORIGIN: string;
RATE_LIMIT_WINDOW_MS: number;
RATE_LIMIT_MAX_REQUESTS: number;
ENABLE_METRICS: boolean;
DISPUTE_NOTIFICATION_WEBHOOK_URL: string;
UPLOAD_STORAGE_DRIVER: string;
UPLOAD_LOCAL_DIR: string;
Expand Down Expand Up @@ -269,6 +270,12 @@
CORS_ORIGIN: z.string().default('*'),
RATE_LIMIT_WINDOW_MS: numeric(z.coerce.number().int().min(1000).default(900000)),
RATE_LIMIT_MAX_REQUESTS: numeric(z.coerce.number().int().min(1).default(100)),
ENABLE_METRICS: z.preprocess(
blankToUndefined,
z.enum(['true', 'false'])

Check failure on line 275 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Insert `⏎······`
.default('false')
.transform((value) => value === 'true'),
),
DISPUTE_NOTIFICATION_WEBHOOK_URL: z.string().default(''),

// ── Uploads / storage ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -297,8 +304,13 @@
PROFILE_PICTURE_QUALITY: z.string().optional(),

// ── Indexer lag monitoring ─────────────────────────────────────────────────
INDEXER_LAG_ALERT_THRESHOLD: numeric(z.coerce.number().int().min(1).default(100)),
INDEXER_LAG_CHECK_INTERVAL_MS: numeric(z.coerce.number().int().min(1000).default(60000)),
// ── Indexer lag monitoring ─────────────────────────────────────────────────
INDEXER_LAG_ALERT_THRESHOLD: numeric(

Check failure on line 308 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Replace `⏎····z.coerce.number().int().min(1).default(100),⏎··` with `z.coerce.number().int().min(1).default(100)`
z.coerce.number().int().min(1).default(100),
),
INDEXER_LAG_CHECK_INTERVAL_MS: numeric(

Check failure on line 311 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Replace `⏎····z.coerce.number().int().min(1000).default(60000),⏎··` with `z.coerce.number().int().min(1000).default(60000)`
z.coerce.number().int().min(1000).default(60000),
),
INDEXER_LAG_WEBHOOK_URL: z.string().trim().default(''),

// ── Soroban RPC retry config ────────────────────────────────────────────────
Expand Down Expand Up @@ -434,18 +446,18 @@
env = envSchema.parse(process.env);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('❌ Invalid environment variables:');

Check warning on line 449 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Unexpected console statement
error.issues.forEach((issue) => {
console.error(` - ${issue.path.join('.')}: ${issue.message}`);

Check warning on line 451 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Unexpected console statement
});
} else {
console.error('❌ Failed to parse environment variables:', error);

Check warning on line 454 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Unexpected console statement
}
process.exit(1);
}

if (env.UPLOAD_STORAGE_DRIVER === 's3' && !env.AWS_S3_BUCKET) {
console.error('❌ AWS_S3_BUCKET is required when UPLOAD_STORAGE_DRIVER=s3');

Check warning on line 460 in src/config/env.ts

View workflow job for this annotation

GitHub Actions / build (22.x, 6.0)

Unexpected console statement
process.exit(1);
}

Expand Down
23 changes: 23 additions & 0 deletions src/controllers/metricsController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Request, Response, NextFunction } from 'express';
import { StatusCodes } from 'http-status-codes';
import prometheusMetricsService from '../services/prometheusMetricsService';

/**
* Exposes operational metrics in Prometheus text exposition format.
* Authentication and feature gating are enforced by metricsRoutes.
*/
export const getPrometheusMetrics = async (
_req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const output = await prometheusMetricsService.render();
res
.status(StatusCodes.OK)
.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
res.send(output);
} catch (error) {
next(error);
}
};
33 changes: 33 additions & 0 deletions src/middleware/prometheusMetricsMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Request, Response, NextFunction } from 'express';
import prometheusMetricsService from '../services/prometheusMetricsService';

/**
* Records completed HTTP requests using route templates to avoid unbounded
* cardinality from identifiers and query parameters.
*/
const prometheusMetricsMiddleware = (req: Request, res: Response, next: NextFunction): void => {
const path = req.path;
if (
path === '/metrics' ||
path === '/metrics/' ||
path === '/api/v1/metrics' ||
path === '/api/v1/metrics/'
) {
next();
return;
}

const startedAt = process.hrtime.bigint();
res.once('finish', () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const routePath =
req.route && typeof req.route.path === 'string'
? req.baseUrl + req.route.path || req.route.path
: 'unmatched';
prometheusMetricsService.recordRequest(req.method, routePath, res.statusCode, durationMs);
});

next();
};

export default prometheusMetricsMiddleware;
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import fleetRoutes from './fleetRoutes';
import disputeRoutes from './disputeRoutes';
import eventLogRoutes from './eventLogRoutes';
import monitorRoutes from './monitorRoutes';
import metricsRoutes from './metricsRoutes';
import profileRoutes from './profileRoutes';
import notificationRoutes from './notificationRoutes';
import healthRoutes from './healthRoutes';
Expand Down Expand Up @@ -52,6 +53,7 @@ router.use('/v1/fleets', fleetRoutes);
router.use('/v1/disputes', disputeRoutes);
router.use('/v1/eventlog', eventLogRoutes);
router.use('/v1/monitor', monitorRoutes);
router.use('/v1/metrics', metricsRoutes);
router.use('/v1/profile', profileRoutes);
router.use('/v1/notifications', notificationRoutes);
router.use('/v1/health', healthRoutes);
Expand Down
46 changes: 46 additions & 0 deletions src/routes/metricsRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Router, Response, NextFunction, Request } from 'express';
import authenticate from '../middleware/authenticate';
import requireRole from '../middleware/requireRole';
import { UserRole } from '../interfaces/IUser';
import env from '../config/env';
import { getPrometheusMetrics } from '../controllers/metricsController';

const router = Router();

/**
* @openapi
* /v1/metrics:
* get:
* tags: [Monitoring]
* summary: Export operational metrics in Prometheus format
* description: Admin-only endpoint. Disabled unless ENABLE_METRICS=true.
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Prometheus text exposition
* content:
* text/plain:
* schema:
* type: string
* 401:
* description: Authentication required
* 403:
* description: Admin role required
* 404:
* description: Metrics endpoint is disabled
*/
const requireMetricsEnabled = (_req: Request, res: Response, next: NextFunction): void => {
if (!env.ENABLE_METRICS) {
res.status(404).end();
return;
}
next();
};

router.use(requireMetricsEnabled);
router.use(authenticate);
router.use(requireRole(UserRole.ADMIN));
router.get('/', getPrometheusMetrics);

export default router;
32 changes: 32 additions & 0 deletions src/services/monitorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,38 @@ export const checkIndexerLag = async (): Promise<IndexerLagCheckResult> => {
return result;
};

/**
* Read-only indexer lag snapshot for scrape-based monitoring.
*
* Unlike checkIndexerLag(), this helper does not create checkpoints, persist
* alerts, or send webhooks, so frequent metrics scrapes have no write side effects.
*/
export interface IndexerLagSnapshot {
network: string;
processedLedger: number;
networkLedger: number;
lagLedgers: number;
}

export const getIndexerLagSnapshot = async (): Promise<IndexerLagSnapshot | null> => {
const status = await IndexerStatus.findOne({ network: stellarConfig.network });
if (!status) {
return null;
}

const rawNetworkLedger = await sorobanService.getLatestLedger();
if (typeof rawNetworkLedger !== 'number') {
return null;
}

return {
network: status.network,
processedLedger: status.lastProcessedLedger,
networkLedger: rawNetworkLedger,
lagLedgers: Math.max(0, rawNetworkLedger - status.lastProcessedLedger),
};
};

// ─── Alert history ─────────────────────────────────────────────────────────────

/**
Expand Down
Loading
Loading