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:
Claude
2025-12-24 14:40:15 +00:00
parent 773da9afb5
commit e6ac7e5572
3 changed files with 191 additions and 1 deletions

View File

@@ -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

View File

@@ -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);

View 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;