From e6ac7e5572ea6ce739eff3d05ff7e06fab57cd0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Dec 2025 14:40:15 +0000 Subject: [PATCH] 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 --- src/components/layouts/AppShell.tsx | 4 +- src/hooks/useReqTimelineEnhanced.ts | 17 +++ src/services/relay-metrics-collector.ts | 171 ++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/services/relay-metrics-collector.ts diff --git a/src/components/layouts/AppShell.tsx b/src/components/layouts/AppShell.tsx index a3eb309..1727e75 100644 --- a/src/components/layouts/AppShell.tsx +++ b/src/components/layouts/AppShell.tsx @@ -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 diff --git a/src/hooks/useReqTimelineEnhanced.ts b/src/hooks/useReqTimelineEnhanced.ts index 9da1414..c0779c8 100644 --- a/src/hooks/useReqTimelineEnhanced.ts +++ b/src/hooks/useReqTimelineEnhanced.ts @@ -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(); + // 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); diff --git a/src/services/relay-metrics-collector.ts b/src/services/relay-metrics-collector.ts new file mode 100644 index 0000000..459d3ea --- /dev/null +++ b/src/services/relay-metrics-collector.ts @@ -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(); + private subscriptions = new Map(); + private pollingInterval: ReturnType | 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;