diff --git a/src/client/GuildPassClient.ts b/src/client/GuildPassClient.ts index d10bd92..37509a2 100644 --- a/src/client/GuildPassClient.ts +++ b/src/client/GuildPassClient.ts @@ -24,6 +24,7 @@ import type { AccessCheckParams, RoleAccessCheckParams, AccessCheckBatchOptions, import type { MembershipParams } from '../membership/membership.types'; import type { GetRolesParams, GetUserRolesParams, HasRoleParams } from '../roles/roles.types'; import type { GetGuildParams } from '../guilds/guilds.types'; +import { DiagnosticsModule } from '../diagnostics/DiagnosticsModule'; /** * The main GuildPass SDK this. @@ -84,6 +85,7 @@ export class GuildPassClient { public readonly guilds: GuildsService; // GuildPass SDK: Class member structure property or constructor. public readonly contracts: ContractClient; + public readonly diagnostics: DiagnosticsModule; // GuildPass SDK: Class member structure property or constructor. private readonly http: HttpClient; @@ -162,6 +164,38 @@ export class GuildPassClient { this.roles = this.buildCachedRolesService(rawRoles); this.guilds = this.buildCachedGuildsService(rawGuilds); this.contracts = rawContracts; + + this.diagnostics = new DiagnosticsModule(); + this.diagnostics.registerInFlightRequests(() => this.inFlightRequests.size); + this.diagnostics.registerRateLimit(() => this.http.getRateLimitStatus()); + this.diagnostics.registerCircuitBreakers(() => this.contracts.getCircuitBreakerSnapshot()); + + // Hook up rate limit events if token bucket exists + if (this.config.rateLimit) { + const originalOnThrottled = (this.config.rateLimit as any).onThrottled; + (this.config.rateLimit as any).onThrottled = (until: number) => { + this.diagnostics.emit('rateLimitThrottled', { throttlingUntil: until }); + if (originalOnThrottled) originalOnThrottled(until); + }; + } + + // Hook up circuit breaker events if using adaptive provider + if (this.config.contractProvider && (this.config.contractProvider as any).healthTracker) { + const tracker = (this.config.contractProvider as any).healthTracker; + if (tracker && tracker.config) { + const originalOnCircuitOpen = tracker.config.onCircuitOpen; + const originalOnCircuitClosed = tracker.config.onCircuitClosed; + tracker.config.onCircuitOpen = (url: string, until: number) => { + this.diagnostics.emit('circuitOpen', { url, openUntil: until }); + if (originalOnCircuitOpen) originalOnCircuitOpen(url, until); + }; + tracker.config.onCircuitClosed = (url: string) => { + this.diagnostics.emit('circuitClosed', { url }); + if (originalOnCircuitClosed) originalOnCircuitClosed(url); + }; + } + } + // GuildPass SDK: End of logic containment structure block. } @@ -318,11 +352,17 @@ export class GuildPassClient { if (this.cache) { try { const cached = await this.cache.get(key); - if (cached !== null) return cached; + if (cached !== null) { + this.diagnostics.recordCacheHit(key); + return cached; + } } catch (error: any) { + this.diagnostics.recordCacheError(); this.handleCacheError('get', error, key); } } + + this.diagnostics.recordCacheMiss(key); const execute = async (): Promise => { const result = await fn(); diff --git a/src/contracts/contractClient.ts b/src/contracts/contractClient.ts index a8ee7e0..3b2a165 100644 --- a/src/contracts/contractClient.ts +++ b/src/contracts/contractClient.ts @@ -719,6 +719,16 @@ export class ContractClient { return result; } + public getCircuitBreakerSnapshot(): Record { + // ContractClient doesn't hold the AdaptiveContractProvider directly if it is passed in, + // but we can check if the contractProvider is AdaptiveContractProvider and has healthTracker. + const provider = this.config.contractProvider as any; + if (provider && provider.healthTracker && typeof provider.healthTracker.snapshotAll === 'function') { + return provider.healthTracker.snapshotAll(); + } + return {}; + } + /** * Fetches the owner of a guild from the contract. * diff --git a/src/contracts/providers/adaptive.types.ts b/src/contracts/providers/adaptive.types.ts index 5d526fc..a739a4a 100644 --- a/src/contracts/providers/adaptive.types.ts +++ b/src/contracts/providers/adaptive.types.ts @@ -49,6 +49,10 @@ export type AdaptiveHealthConfig = { failureThreshold?: number; /** How long the circuit stays open before a trial call, ms. Default: 30000. */ cooldownMs?: number; + /** Callback fired when a circuit breaker opens. */ + onCircuitOpen?: (url: string, openUntil: number) => void; + /** Callback fired when a circuit breaker closes. */ + onCircuitClosed?: (url: string) => void; /** Smoothing factor for the latency EMA, between 0 and 1. Default: 0.3. */ latencyEmaAlpha?: number; /** Batch size at or above which Multicall3 is strongly preferred. Default: 3. */ diff --git a/src/contracts/providers/healthTracker.ts b/src/contracts/providers/healthTracker.ts index c7ec3ce..32949e0 100644 --- a/src/contracts/providers/healthTracker.ts +++ b/src/contracts/providers/healthTracker.ts @@ -1,7 +1,10 @@ // GuildPass SDK: Pull in package or module bindings. import { AdaptiveHealthConfig, UrlHealth } from './adaptive.types'; -const DEFAULTS: Required = { +const DEFAULTS: Omit, 'onCircuitOpen' | 'onCircuitClosed'> & { + onCircuitOpen?: (url: string, openUntil: number) => void; + onCircuitClosed?: (url: string) => void; +} = { failureThreshold: 3, cooldownMs: 30000, latencyEmaAlpha: 0.3, @@ -24,7 +27,10 @@ const DEFAULTS: Required = { * response does not dominate routing decisions. */ export class HealthTracker { - private readonly config: Required; + private readonly config: Omit, 'onCircuitOpen' | 'onCircuitClosed'> & { + onCircuitOpen?: (url: string, openUntil: number) => void; + onCircuitClosed?: (url: string) => void; + }; private readonly health = new Map(); constructor(config?: AdaptiveHealthConfig) { @@ -56,6 +62,9 @@ export class HealthTracker { if (record.circuitOpen && now >= record.openUntil) { // Cooldown elapsed: allow a half-open trial call through. record.circuitOpen = false; + if (this.config.onCircuitClosed) { + this.config.onCircuitClosed(url); + } } return !record.circuitOpen; } @@ -63,6 +72,7 @@ export class HealthTracker { /** Records a successful call: resets failures and updates latency EMA. */ public recordSuccess(url: string, latencyMs: number): void { const record = this.get(url); + const wasOpen = record.circuitOpen; record.consecutiveFailures = 0; record.circuitOpen = false; record.openUntil = 0; @@ -71,6 +81,10 @@ export class HealthTracker { ? latencyMs : this.config.latencyEmaAlpha * latencyMs + (1 - this.config.latencyEmaAlpha) * record.latencyEmaMs; + + if (wasOpen && this.config.onCircuitClosed) { + this.config.onCircuitClosed(url); + } } /** @@ -80,9 +94,12 @@ export class HealthTracker { public recordFailure(url: string, now: number = Date.now()): void { const record = this.get(url); record.consecutiveFailures += 1; - if (record.consecutiveFailures >= this.config.failureThreshold) { + if (!record.circuitOpen && record.consecutiveFailures >= this.config.failureThreshold) { record.circuitOpen = true; record.openUntil = now + this.config.cooldownMs; + if (this.config.onCircuitOpen) { + this.config.onCircuitOpen(url, record.openUntil); + } } } @@ -102,4 +119,12 @@ export class HealthTracker { const record = this.health.get(url); return record ? { ...record } : undefined; } + + public snapshotAll(): Record> { + const result: Record> = {}; + for (const [url, record] of this.health.entries()) { + result[url] = { ...record }; + } + return result; + } } diff --git a/src/diagnostics/DiagnosticsModule.ts b/src/diagnostics/DiagnosticsModule.ts new file mode 100644 index 0000000..cebea40 --- /dev/null +++ b/src/diagnostics/DiagnosticsModule.ts @@ -0,0 +1,75 @@ +import { + CacheDiagnostics, + CircuitBreakerDiagnostics, + DiagnosticsEvents, + DiagnosticsSnapshot, + RateLimitDiagnostics, +} from './diagnostics.types'; + +export class DiagnosticsModule { + private cacheStats: CacheDiagnostics = { hits: 0, misses: 0, errors: 0 }; + private listeners: Record> = {}; + + private inFlightRequestsProvider?: () => number; + private rateLimitProvider?: () => RateLimitDiagnostics | null; + private circuitBreakersProvider?: () => Record; + + public on(event: K, handler: DiagnosticsEvents[K]): void { + if (!this.listeners[event as string]) { + this.listeners[event as string] = new Set(); + } + this.listeners[event as string].add(handler); + } + + public off(event: K, handler: DiagnosticsEvents[K]): void { + if (this.listeners[event as string]) { + this.listeners[event as string].delete(handler); + } + } + + public emit( + event: K, + ...args: Parameters + ): void { + if (this.listeners[event as string]) { + for (const handler of this.listeners[event as string]) { + handler(...args); + } + } + } + + public registerInFlightRequests(fn: () => number): void { + this.inFlightRequestsProvider = fn; + } + + public registerRateLimit(fn: () => RateLimitDiagnostics | null): void { + this.rateLimitProvider = fn; + } + + public registerCircuitBreakers(fn: () => Record): void { + this.circuitBreakersProvider = fn; + } + + public recordCacheHit(key: string): void { + this.cacheStats.hits++; + this.emit('cacheHit', { key }); + } + + public recordCacheMiss(key: string): void { + this.cacheStats.misses++; + this.emit('cacheMiss', { key }); + } + + public recordCacheError(): void { + this.cacheStats.errors++; + } + + public getSnapshot(): DiagnosticsSnapshot { + return { + inFlightRequests: this.inFlightRequestsProvider ? this.inFlightRequestsProvider() : 0, + cache: { ...this.cacheStats }, + rateLimit: this.rateLimitProvider ? this.rateLimitProvider() : null, + circuitBreakers: this.circuitBreakersProvider ? this.circuitBreakersProvider() : {}, + }; + } +} diff --git a/src/diagnostics/diagnostics.types.ts b/src/diagnostics/diagnostics.types.ts new file mode 100644 index 0000000..3545313 --- /dev/null +++ b/src/diagnostics/diagnostics.types.ts @@ -0,0 +1,32 @@ +export interface CacheDiagnostics { + hits: number; + misses: number; + errors: number; +} + +export interface CircuitBreakerDiagnostics { + circuitOpen: boolean; + consecutiveFailures: number; + latencyEmaMs: number; + openUntil: number; +} + +export interface RateLimitDiagnostics { + throttlingUntil: number; + currentRate: number; +} + +export interface DiagnosticsSnapshot { + inFlightRequests: number; + cache: CacheDiagnostics; + rateLimit: RateLimitDiagnostics | null; + circuitBreakers: Record; +} + +export interface DiagnosticsEvents { + circuitOpen: (data: { url: string; openUntil: number }) => void; + circuitClosed: (data: { url: string }) => void; + rateLimitThrottled: (data: { throttlingUntil: number }) => void; + cacheHit: (data: { key: string }) => void; + cacheMiss: (data: { key: string }) => void; +} diff --git a/src/diagnostics/index.ts b/src/diagnostics/index.ts new file mode 100644 index 0000000..07b321a --- /dev/null +++ b/src/diagnostics/index.ts @@ -0,0 +1,2 @@ +export * from './diagnostics.types'; +export * from './DiagnosticsModule'; diff --git a/src/http/httpClient.ts b/src/http/httpClient.ts index 5a544c5..f7939ef 100644 --- a/src/http/httpClient.ts +++ b/src/http/httpClient.ts @@ -471,4 +471,12 @@ export class HttpClient { } } } + + public getRateLimitStatus(): { throttlingUntil: number; currentRate: number } | null { + if (!this.tokenBucket) return null; + return { + throttlingUntil: this.tokenBucket.getThrottlingUntil(), + currentRate: this.tokenBucket.getCurrentRate(), + }; + } } \ No newline at end of file diff --git a/src/http/tokenBucket.ts b/src/http/tokenBucket.ts index 63f1940..4f2cb51 100644 --- a/src/http/tokenBucket.ts +++ b/src/http/tokenBucket.ts @@ -1,6 +1,7 @@ export type RateLimitConfig = { requestsPerSecond: number; burst?: number; + onThrottled?: (throttlingUntil: number) => void; }; function delay(ms: number): Promise { @@ -16,7 +17,10 @@ export class TokenBucket { private lastRefill: number; private retryUntil: number = 0; // Timestamp until which requests are throttled + private config: RateLimitConfig; + constructor(config: RateLimitConfig) { + this.config = config; const { requestsPerSecond, burst } = config; this.baseRate = requestsPerSecond; this.currentRate = requestsPerSecond; @@ -66,6 +70,10 @@ export class TokenBucket { if (this.currentRate < 0.01) this.currentRate = 0.01; this.refillRate = this.currentRate / 1000; this.tokens = Math.min(this.tokens, this.capacity); + + if (this.config.onThrottled) { + this.config.onThrottled(this.retryUntil); + } } onSuccess(): void { @@ -78,4 +86,8 @@ export class TokenBucket { public getThrottlingUntil(): number { return this.retryUntil > Date.now() ? this.retryUntil : 0; } + + public getCurrentRate(): number { + return this.currentRate; + } } \ No newline at end of file diff --git a/src/testing/mockClient.ts b/src/testing/mockClient.ts index 81aadfc..9b1e833 100644 --- a/src/testing/mockClient.ts +++ b/src/testing/mockClient.ts @@ -6,6 +6,7 @@ import { RolesService } from '../roles/roles.service'; import { ContractClient } from '../contracts/contractClient'; import { DEFAULT_ACCESS_RESULT, DEFAULT_GUILD, DEFAULT_GUILD_CONFIG, DEFAULT_ROLE, DEFAULT_MEMBERSHIP } from './fixtures'; import type { PublicClientConfig } from '../config/sdkConfig'; +import { DiagnosticsModule } from '../diagnostics/DiagnosticsModule'; /** * Utility type to extract only the public properties and methods of a class. @@ -87,6 +88,7 @@ export function createMockGuildPassClient(overrides?: MockClientOverrides): Guil invalidateWalletCache: async () => {}, clearCache: async () => {}, getConfig: overrides?.getConfig ?? (() => ({ apiUrl: 'https://mock.guildpass.xyz' })), + diagnostics: new DiagnosticsModule(), }; // Cast safely. TypeScript guarantees that `mockClientPublic` shapes up to the public interface. diff --git a/tests/diagnostics.test.ts b/tests/diagnostics.test.ts new file mode 100644 index 0000000..3436b46 --- /dev/null +++ b/tests/diagnostics.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from 'vitest'; +import { GuildPassClient, InMemoryCacheAdapter, AdaptiveContractProvider } from '../src'; + +describe('DiagnosticsModule', () => { + it('should expose a unified snapshot with at least three operational states', async () => { + const cache = new InMemoryCacheAdapter(); + const adaptiveProvider = new AdaptiveContractProvider({ + post: async () => ({ data: '0x' }) + } as any, ['http://rpc1.test']); + + const client = new GuildPassClient({ + apiUrl: 'https://api.test', + cache, + contractProvider: adaptiveProvider, + rateLimit: { requestsPerSecond: 10 }, + }); + + // Manually simulate in-flight requests to test the wiring + (client as any).inFlightRequests.set('dummy1', Promise.resolve()); + (client as any).inFlightRequests.set('dummy2', Promise.resolve()); + + const snap1 = client.diagnostics.getSnapshot(); + expect(snap1.inFlightRequests).toBe(2); + + // Simulate cache hit/miss to test wiring + client.diagnostics.recordCacheHit('dummy'); + client.diagnostics.recordCacheMiss('dummy2'); + client.diagnostics.recordCacheMiss('dummy3'); + + const snap2 = client.diagnostics.getSnapshot(); + expect(snap2.cache.hits).toBe(1); + expect(snap2.cache.misses).toBe(2); + + // Rate limit + expect(snap2.rateLimit).not.toBeNull(); + expect(snap2.rateLimit?.currentRate).toBeDefined(); + + // Circuit breakers + expect(snap2.circuitBreakers).toBeDefined(); + }); + + it('should fire event emitter correctly for state transitions', async () => { + const adaptiveProvider = new AdaptiveContractProvider({ + post: async () => ({ data: '0x' }) + } as any, ['http://rpc.test'], { health: { failureThreshold: 1, cooldownMs: 5000 } }); + + const client = new GuildPassClient({ + apiUrl: 'https://api.test', + contractProvider: adaptiveProvider, + }); + + const onCircuitOpen = vi.fn(); + client.diagnostics.on('circuitOpen', onCircuitOpen); + + // Manually trip the circuit breaker on the health tracker + const tracker = (adaptiveProvider as any).healthTracker; + tracker.recordFailure('http://rpc.test', Date.now()); + + expect(onCircuitOpen).toHaveBeenCalled(); + const eventArg = onCircuitOpen.mock.calls[0][0]; + expect(eventArg.url).toBe('http://rpc.test'); + expect(eventArg.openUntil).toBeGreaterThan(Date.now()); + }); +});