mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
feat(relay-metrics): integrate scoreboard with pool events
Add RelayMetricsCollector service that hooks into the relay pool: - Tracks connection time when WebSocket connects - Tracks session duration when connection drops - Integrates with useReqTimelineEnhanced to record response times Integration points: - AppShell.tsx: Initialize collector on app start - useReqTimelineEnhanced: Record response time on EOSE, failures on error
This commit is contained in:
@@ -4,6 +4,7 @@ import { useAccountSync } from "@/hooks/useAccountSync";
|
||||
import { useRelayListCacheSync } from "@/hooks/useRelayListCacheSync";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import relayStateManager from "@/services/relay-state-manager";
|
||||
import relayMetricsCollector from "@/services/relay-metrics-collector";
|
||||
import { TabBar } from "../TabBar";
|
||||
import CommandLauncher from "../CommandLauncher";
|
||||
import { GlobalAuthPrompt } from "../GlobalAuthPrompt";
|
||||
@@ -24,11 +25,12 @@ export function AppShell({ children }: AppShellProps) {
|
||||
// Auto-cache kind:10002 relay lists from EventStore to Dexie
|
||||
useRelayListCacheSync();
|
||||
|
||||
// Initialize global relay state manager
|
||||
// Initialize global relay state manager and metrics collector
|
||||
useEffect(() => {
|
||||
relayStateManager.initialize().catch((err) => {
|
||||
console.error("Failed to initialize relay state manager:", err);
|
||||
});
|
||||
relayMetricsCollector.initialize();
|
||||
}, []);
|
||||
|
||||
// Sync relay state with Jotai
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEventStore } from "applesauce-react/hooks";
|
||||
import { isNostrEvent } from "@/lib/type-guards";
|
||||
import { useStableValue, useStableArray } from "./useStable";
|
||||
import { useRelayState } from "./useRelayState";
|
||||
import relayMetricsCollector from "@/services/relay-metrics-collector";
|
||||
import type { ReqRelayState, ReqOverallState } from "@/types/req-state";
|
||||
import { deriveOverallState } from "@/lib/req-state-machine";
|
||||
|
||||
@@ -185,11 +186,15 @@ export function useReqTimelineEnhanced(
|
||||
limit: limit || f.limit,
|
||||
}));
|
||||
|
||||
// Track subscription start times for response time metrics
|
||||
const subscriptionStartTimes = new Map<string, number>();
|
||||
|
||||
// CRITICAL FIX: Subscribe to each relay INDIVIDUALLY to get per-relay EOSE
|
||||
// Previously used pool.subscription() which only emits EOSE when ALL relays finish
|
||||
// Now we track each relay separately for accurate per-relay EOSE detection
|
||||
const subscriptions = relays.map((url) => {
|
||||
const relay = pool.relay(url);
|
||||
subscriptionStartTimes.set(url, Date.now());
|
||||
|
||||
return relay
|
||||
.subscription(filtersWithLimit, {
|
||||
@@ -203,6 +208,14 @@ export function useReqTimelineEnhanced(
|
||||
if (typeof response === "string" && response === "EOSE") {
|
||||
console.log("REQ Enhanced: EOSE received from", url);
|
||||
|
||||
// Record response time for relay scoring
|
||||
const startTime = subscriptionStartTimes.get(url);
|
||||
if (startTime) {
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
relayMetricsCollector.recordResponseTime(url, responseTimeMs);
|
||||
relayMetricsCollector.recordSuccess(url);
|
||||
}
|
||||
|
||||
// Mark THIS specific relay as having received EOSE
|
||||
setRelayStates((prev) => {
|
||||
const state = prev.get(url);
|
||||
@@ -291,6 +304,10 @@ export function useReqTimelineEnhanced(
|
||||
},
|
||||
(err: Error) => {
|
||||
console.error("REQ Enhanced: Error from", url, err);
|
||||
|
||||
// Record failure for relay scoring
|
||||
relayMetricsCollector.recordFailure(url);
|
||||
|
||||
// Mark this relay as errored
|
||||
setRelayStates((prev) => {
|
||||
const state = prev.get(url);
|
||||
|
||||
171
src/services/relay-metrics-collector.ts
Normal file
171
src/services/relay-metrics-collector.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Relay Metrics Collector
|
||||
*
|
||||
* Hooks into the relay pool to automatically collect performance metrics
|
||||
* for the RelayScoreboard. Tracks:
|
||||
* - Connection time (WebSocket connect duration)
|
||||
* - Session duration (time connected before disconnect)
|
||||
* - Response time (time from subscription start to EOSE)
|
||||
*
|
||||
* This is a singleton that initializes once when the app starts.
|
||||
*/
|
||||
|
||||
import type { IRelay } from "applesauce-relay";
|
||||
import type { Subscription } from "rxjs";
|
||||
import { distinctUntilChanged, pairwise, startWith } from "rxjs/operators";
|
||||
import pool from "./relay-pool";
|
||||
import relayScoreboard from "./relay-scoreboard";
|
||||
|
||||
// Track connection timing per relay
|
||||
interface ConnectionTiming {
|
||||
connectingStartedAt?: number;
|
||||
connectedAt?: number;
|
||||
}
|
||||
|
||||
class RelayMetricsCollector {
|
||||
private connectionTimings = new Map<string, ConnectionTiming>();
|
||||
private subscriptions = new Map<string, Subscription>();
|
||||
private pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the collector
|
||||
* Starts monitoring all relays in the pool
|
||||
*/
|
||||
initialize(): void {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
|
||||
console.debug("[RelayMetricsCollector] Initializing...");
|
||||
|
||||
// Monitor existing relays
|
||||
pool.relays.forEach((relay) => {
|
||||
this.monitorRelay(relay);
|
||||
});
|
||||
|
||||
// Poll for new relays every second
|
||||
this.pollingInterval = setInterval(() => {
|
||||
pool.relays.forEach((relay) => {
|
||||
if (!this.subscriptions.has(relay.url)) {
|
||||
this.monitorRelay(relay);
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
console.debug("[RelayMetricsCollector] Initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor a single relay for connection state changes
|
||||
*/
|
||||
private monitorRelay(relay: IRelay): void {
|
||||
const url = relay.url;
|
||||
|
||||
// Skip if already monitoring
|
||||
if (this.subscriptions.has(url)) return;
|
||||
|
||||
// Initialize timing
|
||||
this.connectionTimings.set(url, {});
|
||||
|
||||
// Track when connecting starts (before WebSocket opens)
|
||||
// We'll use the transition from false->true in connected$
|
||||
const subscription = relay.connected$
|
||||
.pipe(
|
||||
startWith(relay.connected),
|
||||
distinctUntilChanged(),
|
||||
pairwise(), // Emit [previous, current] pairs
|
||||
)
|
||||
.subscribe(([wasConnected, isConnected]) => {
|
||||
const timing = this.connectionTimings.get(url) || {};
|
||||
const now = Date.now();
|
||||
|
||||
if (!wasConnected && isConnected) {
|
||||
// Just connected
|
||||
const connectingStartedAt = timing.connectingStartedAt || now;
|
||||
const connectTimeMs = now - connectingStartedAt;
|
||||
|
||||
// Record connection time
|
||||
relayScoreboard.recordConnect(url, connectTimeMs);
|
||||
|
||||
// Update timing
|
||||
timing.connectedAt = now;
|
||||
timing.connectingStartedAt = undefined;
|
||||
|
||||
console.debug(
|
||||
`[RelayMetricsCollector] ${url} connected in ${connectTimeMs}ms`,
|
||||
);
|
||||
} else if (wasConnected && !isConnected) {
|
||||
// Just disconnected
|
||||
if (timing.connectedAt) {
|
||||
const sessionDurationMs = now - timing.connectedAt;
|
||||
|
||||
// Record session duration
|
||||
relayScoreboard.recordSessionEnd(url, sessionDurationMs);
|
||||
|
||||
console.debug(
|
||||
`[RelayMetricsCollector] ${url} disconnected after ${Math.round(sessionDurationMs / 1000)}s`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reset timing for next connection
|
||||
timing.connectedAt = undefined;
|
||||
timing.connectingStartedAt = now; // Start timing next connect attempt
|
||||
}
|
||||
|
||||
this.connectionTimings.set(url, timing);
|
||||
});
|
||||
|
||||
this.subscriptions.set(url, subscription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a query response time for a relay
|
||||
* Called by subscription handlers when EOSE is received
|
||||
*/
|
||||
recordResponseTime(url: string, responseTimeMs: number): void {
|
||||
relayScoreboard.recordResponse(url, responseTimeMs);
|
||||
console.debug(
|
||||
`[RelayMetricsCollector] ${url} responded in ${responseTimeMs}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful query for a relay
|
||||
*/
|
||||
recordSuccess(url: string): void {
|
||||
relayScoreboard.recordSuccess(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed query for a relay
|
||||
*/
|
||||
recordFailure(url: string): void {
|
||||
relayScoreboard.recordFailure(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all subscriptions
|
||||
*/
|
||||
cleanup(): void {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval);
|
||||
this.pollingInterval = null;
|
||||
}
|
||||
|
||||
this.subscriptions.forEach((sub) => sub.unsubscribe());
|
||||
this.subscriptions.clear();
|
||||
this.connectionTimings.clear();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the collector is initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const relayMetricsCollector = new RelayMetricsCollector();
|
||||
export default relayMetricsCollector;
|
||||
Reference in New Issue
Block a user