Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions backend/src/config/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,11 @@ const logger = winston.createLogger({
});

export default logger;

/**
* Creates a child logger pre-bound with request context.
* Standard context shape: { correlationId, userId, action, durationMs }
*/
export function withContext(loggerInstance, ctx) {
return loggerInstance.child(ctx);
}
139 changes: 136 additions & 3 deletions backend/src/monitoring/metrics.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/**
* Lightweight performance metrics collector.
* Tracks API response times, memory, CPU, and custom metrics.
* Exposes data via /api/metrics endpoint.
* Integrates with external APM (New Relic / DataDog) via env config.
* Tracks API response times, memory, CPU, custom metrics, and business KPIs.
* Exposes data via /api/metrics endpoint (JSON or Prometheus text format).
*/

const metrics = {
Expand All @@ -11,6 +10,71 @@ const metrics = {
alerts: [],
};

// ── Business counters ────────────────────────────────────────────────────────
const counters = {
payments_total: 0,
payments_failed_total: 0,
accounts_created_total: 0,
};

// ── Business gauges ──────────────────────────────────────────────────────────
const gauges = {
active_streams: 0,
pending_multisig_transactions: 0,
};

// ── Business histograms ──────────────────────────────────────────────────────
// Each histogram stores { sum, count, buckets: Map<le, count> }
const PAYMENT_AMOUNT_BUCKETS = [1, 10, 100, 1000, 10000, Infinity];
const STELLAR_API_DURATION_BUCKETS = [0.01, 0.05, 0.1, 0.5, 1, 5, Infinity];

function makeHistogram(bounds) {
const buckets = new Map(bounds.map((b) => [b, 0]));
return { sum: 0, count: 0, buckets };
}

const histograms = {
payment_amount_xlm: makeHistogram(PAYMENT_AMOUNT_BUCKETS),
stellar_api_duration_seconds: makeHistogram(STELLAR_API_DURATION_BUCKETS),
};

function observeHistogram(name, value) {
const h = histograms[name];
if (!h) return;
h.sum += value;
h.count += 1;
for (const [le] of h.buckets) {
if (value <= le) h.buckets.set(le, h.buckets.get(le) + 1);
}
}

// ── Public business-metric helpers ───────────────────────────────────────────

export function incrementCounter(name, by = 1) {
if (name in counters) counters[name] += by;
}

export function setGauge(name, value) {
if (name in gauges) gauges[name] = value;
}

export function recordPayment({ amountXlm, failed = false }) {
if (failed) {
counters.payments_failed_total += 1;
} else {
counters.payments_total += 1;
if (amountXlm != null) observeHistogram('payment_amount_xlm', Number(amountXlm));
}
}

export function recordStellarApiCall(durationSeconds) {
observeHistogram('stellar_api_duration_seconds', durationSeconds);
}

export function recordAccountCreated() {
counters.accounts_created_total += 1;
}

const ALERT_THRESHOLDS = {
responseTimeMs: Number(process.env.PERF_ALERT_RESPONSE_MS ?? 2000),
errorRate: Number(process.env.PERF_ALERT_ERROR_RATE ?? 0.1),
Expand Down Expand Up @@ -51,6 +115,71 @@ function addAlert(type, data) {
if (metrics.alerts.length > 100) metrics.alerts.shift(); // keep last 100
}

// ── Prometheus text format ───────────────────────────────────────────────────

export function toPrometheusText() {
const lines = [];

function counter(name, help, value, labels = '') {
lines.push(`# HELP ${name} ${help}`);
lines.push(`# TYPE ${name} counter`);
lines.push(`${name}${labels ? `{${labels}}` : ''} ${value}`);
}

function gauge(name, help, value, labels = '') {
lines.push(`# HELP ${name} ${help}`);
lines.push(`# TYPE ${name} gauge`);
lines.push(`${name}${labels ? `{${labels}}` : ''} ${value}`);
}

function histogram(name, help, h) {
lines.push(`# HELP ${name} ${help}`);
lines.push(`# TYPE ${name} histogram`);
for (const [le, count] of h.buckets) {
const leLabel = le === Infinity ? '+Inf' : String(le);
lines.push(`${name}_bucket{le="${leLabel}"} ${count}`);
}
lines.push(`${name}_sum ${h.sum}`);
lines.push(`${name}_count ${h.count}`);
}

// Business counters
counter('payments_total', 'Total number of successful payments', counters.payments_total);
counter('payments_failed_total', 'Total number of failed payments', counters.payments_failed_total);
counter('accounts_created_total', 'Total number of accounts created', counters.accounts_created_total);

// Business gauges
gauge('active_streams', 'Number of currently active payment streams', gauges.active_streams);
gauge('pending_multisig_transactions', 'Number of pending multisig transactions', gauges.pending_multisig_transactions);

// Business histograms
histogram('payment_amount_xlm', 'Distribution of payment amounts in XLM', histograms.payment_amount_xlm);
histogram('stellar_api_duration_seconds', 'Duration of Stellar API calls in seconds', histograms.stellar_api_duration_seconds);

// Infrastructure: memory
const mem = process.memoryUsage();
gauge('nodejs_heap_used_bytes', 'Node.js heap used in bytes', mem.heapUsed);
gauge('nodejs_heap_total_bytes', 'Node.js heap total in bytes', mem.heapTotal);
gauge('nodejs_rss_bytes', 'Node.js resident set size in bytes', mem.rss);

// Infrastructure: uptime
gauge('nodejs_process_uptime_seconds', 'Node.js process uptime in seconds', process.uptime());

// Per-route request counters
lines.push('# HELP http_requests_total Total HTTP requests per route');
lines.push('# TYPE http_requests_total counter');
for (const [route, m] of metrics.requests) {
lines.push(`http_requests_total{route="${route}"} ${m.count}`);
}
lines.push('# HELP http_errors_total Total HTTP errors per route');
lines.push('# TYPE http_errors_total counter');
for (const [route, m] of metrics.requests) {
lines.push(`http_errors_total{route="${route}"} ${m.errors}`);
}

return lines.join('\n') + '\n';
}

export function getSnapshot() {
const mem = process.memoryUsage();
const cpuUsage = process.cpuUsage();
Expand Down Expand Up @@ -92,4 +221,8 @@ export function resetMetrics() {
metrics.requests.clear();
metrics.custom.clear();
metrics.alerts.length = 0;
for (const k of Object.keys(counters)) counters[k] = 0;
for (const k of Object.keys(gauges)) gauges[k] = 0;
histograms.payment_amount_xlm = makeHistogram(PAYMENT_AMOUNT_BUCKETS);
histograms.stellar_api_duration_seconds = makeHistogram(STELLAR_API_DURATION_BUCKETS);
}
11 changes: 8 additions & 3 deletions backend/src/routes/metrics.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import express from 'express';
import { getSnapshot, resetMetrics } from '../monitoring/metrics.js';
import { getSnapshot, resetMetrics, toPrometheusText } from '../monitoring/metrics.js';
import { getWsStats } from '../services/websocket.js';
import { getFeeBumpStats } from '../services/stellar.js';
import { getCdnStats } from '../cdn/index.js';
import { checkShardHealth, getShardStats } from '../db/sharding.js';

const router = express.Router();

// GET /api/metrics — full performance snapshot
router.get('/', (_req, res) => {
// GET /api/metrics — full snapshot (Prometheus text if Accept: text/plain, else JSON)
router.get('/', (req, res) => {
const accept = req.headers['accept'] ?? '';
if (accept.includes('text/plain') || accept.includes('application/openmetrics-text')) {
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
return res.send(toPrometheusText());
}
res.json(getSnapshot());
});

Expand Down
4 changes: 2 additions & 2 deletions backend/src/services/stellar.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as StellarSDK from '@stellar/stellar-sdk';
import { eventMonitor } from '../eventSourcing/index.js';
import { getConfig } from '../config/env.js';
import { getIssuer } from '../config/assets.js';
import logger from '../config/logger.js';
import logger, { withContext } from '../config/logger.js';
import prisma from '../db/client.js';

export async function getFeeBumpStats() {
Expand Down Expand Up @@ -88,7 +88,7 @@ export async function fundAccount(publicKey) {
export async function createAccount(correlationId = null) {
const pair = StellarSDK.Keypair.random();
const publicKey = pair.publicKey();
logger.info('stellar.createAccount', { publicKey, correlationId });
withContext(logger, { action: 'createAccount', correlationId }).info('stellar.createAccount', { publicKey });

if (isTestnet()) {
const friendbotRes = await fetch(`https://friendbot.stellar.org?addr=${publicKey}`);
Expand Down
8 changes: 4 additions & 4 deletions backend/src/services/streaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import prisma from '../db/client.js';
import { sendPayment } from './stellar.js';
import { eventMonitor } from '../eventSourcing/index.js';
import logger from '../config/logger.js';
import logger, { withContext } from '../config/logger.js';
import { encryptToEnvValue, decryptFromEnvValue } from '../config/secrets.js';

/**
Expand Down Expand Up @@ -225,12 +225,12 @@ export async function processActiveStreams() {
version: 1,
});

logger.info('streaming.process.success', { streamId: stream.id, hash: result.hash });
withContext(logger, { action: 'processStream', correlationId: stream.id }).info('streaming.process.success', { streamId: stream.id, hash: result.hash });
} else {
throw new Error('Transaction submission failed');
}
} catch (err) {
logger.error('streaming.process.failed', { streamId: stream.id, error: err.message });
withContext(logger, { action: 'processStream', correlationId: stream.id }).error('streaming.process.failed', { streamId: stream.id, error: err.message });

const updatedStream = await prisma.paymentStream.update({
where: { id: stream.id },
Expand All @@ -249,7 +249,7 @@ export async function processActiveStreams() {
version: 1,
});

logger.error('streaming.stream.halted', { streamId: stream.id, reason: 'Too many failures' });
withContext(logger, { action: 'processStream', correlationId: stream.id }).error('streaming.stream.halted', { streamId: stream.id, reason: 'Too many failures' });
}
}
}
Expand Down
11 changes: 5 additions & 6 deletions backend/src/services/transactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import * as StellarSDK from '@stellar/stellar-sdk';
import { MultiLevelCache } from '../cache/multi-level.js';
import { eventMonitor } from '../eventSourcing/index.js';
import logger from '../config/logger.js';
import logger, { withContext } from '../config/logger.js';
import { getConfig } from '../config/env.js';

const TRANSACTION_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
Expand Down Expand Up @@ -48,7 +48,7 @@ class TransactionService {
// Check cache first
let transactions = await this.cache.get(cacheKey);
if (transactions) {
logger.debug('Transaction cache hit', { accountId, count: transactions.length });
withContext(logger, { action: 'getTransactions', accountId }).debug('Transaction cache hit', { count: transactions.length });
return transactions;
}

Expand Down Expand Up @@ -91,16 +91,15 @@ class TransactionService {
// Store in event store for persistence
await this.storeTransactions(accountId, enrichedTransactions);

logger.info('Fetched transactions from Horizon', {
accountId,
withContext(logger, { action: 'getTransactions', accountId }).info('Fetched transactions from Horizon', {
count: enrichedTransactions.length,
cursor,
limit
limit,
});

return enrichedTransactions;
} catch (error) {
logger.error('Failed to fetch transactions', { accountId, error: error.message });
withContext(logger, { action: 'getTransactions', accountId }).error('Failed to fetch transactions', { error: error.message });
throw error;
}
}
Expand Down
Loading
Loading