Skip to content
Open
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
50 changes: 50 additions & 0 deletions backend/monitoring/rpcMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export interface RpcMetricsData {
endpoint: string;
latencyMs: number;
errorCount: number;
successCount: number;
circuitState: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
lastFailureTime: number | null;
}

class RpcMetricsRegistry {
private metrics: Map<string, RpcMetricsData> = new Map();

private getOrInit(endpoint: string): RpcMetricsData {
if (!this.metrics.has(endpoint)) {
this.metrics.set(endpoint, {
endpoint,
latencyMs: 0,
errorCount: 0,
successCount: 0,
circuitState: 'CLOSED',
lastFailureTime: null,
});
}
return this.metrics.get(endpoint)!;
}

recordLatency(endpoint: string, latencyMs: number) {
const data = this.getOrInit(endpoint);
// Simple Exponential Moving Average
data.latencyMs = data.latencyMs === 0 ? latencyMs : data.latencyMs * 0.8 + latencyMs * 0.2;
data.successCount++;
}

recordError(endpoint: string) {
const data = this.getOrInit(endpoint);
data.errorCount++;
data.lastFailureTime = Date.now();
}

updateCircuitState(endpoint: string, state: 'CLOSED' | 'OPEN' | 'HALF_OPEN') {
const data = this.getOrInit(endpoint);
data.circuitState = state;
}

getHealthStatus(): RpcMetricsData[] {
return Array.from(this.metrics.values());
}
}

export const rpcMetrics = new RpcMetricsRegistry();
66 changes: 66 additions & 0 deletions backend/services/shared/MonitoringJsonRpcProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ethers } from 'ethers';
import { CircuitBreaker, executeWithTimeout } from './rpcResilience';
import { rpcMetrics } from '../../monitoring/rpcMetrics';

export interface MonitoringJsonRpcProviderOptions {
timeoutMs?: number;
failureThreshold?: number;
resetTimeoutMs?: number;
}

export class MonitoringJsonRpcProvider extends ethers.providers.JsonRpcProvider {
private fallbackUrls: string[];
private currentUrlIndex = 0;
private circuitBreakers: Map<string, CircuitBreaker> = new Map();
private timeoutMs: number;

constructor(urls: string | string[], network?: ethers.providers.Networkish, options?: MonitoringJsonRpcProviderOptions) {
const urlArray = Array.isArray(urls) ? urls : [urls];
super(urlArray[0], network);
this.fallbackUrls = urlArray;

this.timeoutMs = options?.timeoutMs ?? 5000;
const failureThreshold = options?.failureThreshold ?? 3;
const resetTimeoutMs = options?.resetTimeoutMs ?? 30000;

for (const url of this.fallbackUrls) {
this.circuitBreakers.set(url, new CircuitBreaker(url, failureThreshold, resetTimeoutMs));
}
}

// Override connection to always use the current active URL
get connection() {

Check failure on line 32 in backend/services/shared/MonitoringJsonRpcProvider.ts

View workflow job for this annotation

GitHub Actions / Type Check

'connection' is defined as a property in class 'JsonRpcProvider', but is overridden here in 'MonitoringJsonRpcProvider' as an accessor.
const conn = super.connection;

Check failure on line 33 in backend/services/shared/MonitoringJsonRpcProvider.ts

View workflow job for this annotation

GitHub Actions / Type Check

Class field 'connection' defined by the parent class is not accessible in the child class via super.
conn.url = this.fallbackUrls[this.currentUrlIndex];
return conn;
}

async send(method: string, params: Array<any>): Promise<any> {
const originalIndex = this.currentUrlIndex;
let attemptCount = 0;

while (attemptCount < this.fallbackUrls.length) {
const url = this.fallbackUrls[this.currentUrlIndex];
const cb = this.circuitBreakers.get(url)!;

const startTime = Date.now();
try {
const result = await cb.execute(() =>
executeWithTimeout(super.send(method, params), this.timeoutMs)
);
const latency = Date.now() - startTime;
rpcMetrics.recordLatency(url, latency);
return result;
} catch (error: any) {
// If CircuitBreaker is OPEN or request failed/timed out, try next fallback
attemptCount++;
this.currentUrlIndex = (this.currentUrlIndex + 1) % this.fallbackUrls.length;

// If we exhausted all fallbacks, throw the error
if (attemptCount === this.fallbackUrls.length) {
throw new Error(`All RPC endpoints failed. Last error: ${error.message}`);
}
}
}
}
}
78 changes: 78 additions & 0 deletions backend/services/shared/rpcResilience.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { rpcMetrics } from '../../monitoring/rpcMetrics';

export class CircuitBreaker {
private endpoint: string;
private failureThreshold: number;
private resetTimeoutMs: number;

private failures = 0;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
private nextAttemptTime = 0;

constructor(endpoint: string, failureThreshold = 5, resetTimeoutMs = 30000) {
this.endpoint = endpoint;
this.failureThreshold = failureThreshold;
this.resetTimeoutMs = resetTimeoutMs;
}

async execute<T>(action: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() > this.nextAttemptTime) {
this.state = 'HALF_OPEN';
rpcMetrics.updateCircuitState(this.endpoint, 'HALF_OPEN');
} else {
throw new Error(`CircuitBreaker OPEN for endpoint: ${this.endpoint}`);
}
}

try {
const result = await action();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}

private onSuccess() {
this.failures = 0;
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED';
rpcMetrics.updateCircuitState(this.endpoint, 'CLOSED');
}
}

private onFailure() {
this.failures++;
rpcMetrics.recordError(this.endpoint);
if (this.failures >= this.failureThreshold) {
this.state = 'OPEN';
this.nextAttemptTime = Date.now() + this.resetTimeoutMs;
rpcMetrics.updateCircuitState(this.endpoint, 'OPEN');
} else if (this.state === 'HALF_OPEN') {
// If it fails during half-open, immediately re-open
this.state = 'OPEN';
this.nextAttemptTime = Date.now() + this.resetTimeoutMs;
rpcMetrics.updateCircuitState(this.endpoint, 'OPEN');
}
}
}

export function executeWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
}, timeoutMs);

promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
171 changes: 171 additions & 0 deletions developer-portal/components/RpcHealthDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';

// Mocking backend response for demonstration in frontend
interface RpcMetricsData {
endpoint: string;
latencyMs: number;
errorCount: number;
successCount: number;
circuitState: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
lastFailureTime: number | null;
}

export const RpcHealthDashboard: React.FC = () => {
const [metrics, setMetrics] = useState<RpcMetricsData[]>([]);

useEffect(() => {
// In a real implementation, this would fetch from an API like /api/v1/monitoring/rpc
// For now, we mock the data to demonstrate the UI
const mockData: RpcMetricsData[] = [
{
endpoint: 'https://cloudflare-eth.com',
latencyMs: 145,
errorCount: 2,
successCount: 15430,
circuitState: 'CLOSED',
lastFailureTime: null,
},
{
endpoint: 'https://rpc.ankr.com/eth',
latencyMs: 0,
errorCount: 0,
successCount: 0,
circuitState: 'CLOSED',
lastFailureTime: null,
},
{
endpoint: 'https://polygon-rpc.com',
latencyMs: 1205,
errorCount: 5,
successCount: 432,
circuitState: 'OPEN',
lastFailureTime: Date.now() - 5000,
},
{
endpoint: 'https://rpc.ankr.com/polygon',
latencyMs: 85,
errorCount: 0,
successCount: 12,
circuitState: 'CLOSED',
lastFailureTime: null,
}
];
setMetrics(mockData);

const interval = setInterval(() => {
// Simulate real-time updates
setMetrics(prev => prev.map(m => {
if (m.circuitState === 'CLOSED' && m.successCount > 0) {
return { ...m, latencyMs: m.latencyMs + (Math.random() * 10 - 5), successCount: m.successCount + 1 };
}
return m;
}));
}, 2000);

return () => clearInterval(interval);
}, []);

const getStatusColor = (state: string) => {
switch (state) {
case 'CLOSED': return '#22C55E';
case 'OPEN': return '#EF4444';
case 'HALF_OPEN': return '#F59E0B';
default: return '#6B7280';
}
};

return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>RPC Health Monitor</Text>
<Text style={styles.subtitle}>Real-time circuit breaker status and latency</Text>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.cell, styles.headerCell, { flex: 2 }]}>Endpoint</Text>
<Text style={[styles.cell, styles.headerCell]}>Latency</Text>
<Text style={[styles.cell, styles.headerCell]}>Errors</Text>
<Text style={[styles.cell, styles.headerCell]}>Status</Text>
</View>
{metrics.map((item, index) => (
<View key={index} style={styles.tableRow}>
<Text style={[styles.cell, { flex: 2 }]} numberOfLines={1}>{item.endpoint}</Text>
<Text style={styles.cell}>{Math.round(item.latencyMs)} ms</Text>
<Text style={styles.cell}>{item.errorCount}</Text>
<View style={[styles.cell, styles.statusContainer]}>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(item.circuitState) }]} />
<Text style={{ color: getStatusColor(item.circuitState), fontWeight: '600' }}>
{item.circuitState}
</Text>
</View>
</View>
))}
</View>
</ScrollView>
</View>
);
};

const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
marginTop: 16,
paddingVertical: 16,
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: '#E5E7EB',
},
header: {
paddingHorizontal: 16,
marginBottom: 12,
},
title: {
fontSize: 18,
fontWeight: '600',
color: '#111827',
},
subtitle: {
fontSize: 14,
color: '#6B7280',
marginTop: 4,
},
table: {
minWidth: 600,
paddingHorizontal: 16,
},
tableHeader: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
paddingBottom: 8,
marginBottom: 8,
},
tableRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#F9FAFB',
},
cell: {
flex: 1,
fontSize: 14,
color: '#374151',
},
headerCell: {
fontWeight: '600',
color: '#6B7280',
},
statusContainer: {
flexDirection: 'row',
alignItems: 'center',
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 6,
},
});
3 changes: 3 additions & 0 deletions developer-portal/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
import { RpcHealthDashboard } from '../components/RpcHealthDashboard';

interface DashboardStats {
totalRequests: number;
Expand Down Expand Up @@ -225,6 +226,8 @@ export const DashboardPage: React.FC<DashboardPageProps> = ({ onNavigate }) => {
</View>
))}
</View>

<RpcHealthDashboard />
</ScrollView>
);
};
Expand Down
Loading
Loading