Skip to content

Commit f34c411

Browse files
committed
feat: add persistent retry queue for failed telemetry events (#4940)
- Implement TelemetryQueueManager with VSCode storage persistence - Add ConnectionMonitor for real-time connection status tracking - Queue failed telemetry events with exponential backoff retry - Add priority queue for error events - Show connection status in account view UI - Add comprehensive test coverage (43 tests) - Include feature flag for safe rollout
1 parent ebfd384 commit f34c411

21 files changed

+1942
-46
lines changed

packages/cloud/src/CloudService.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { CloudSettingsService } from "./CloudSettingsService"
1919
import { StaticSettingsService } from "./StaticSettingsService"
2020
import { TelemetryClient } from "./TelemetryClient"
2121
import { ShareService, TaskNotFoundError } from "./ShareService"
22+
import { ConnectionMonitor } from "./ConnectionMonitor"
23+
import { TelemetryQueueManager } from "./TelemetryQueueManager"
2224

2325
type AuthStateChangedPayload = CloudServiceEvents["auth-state-changed"][0]
2426
type AuthUserInfoPayload = CloudServiceEvents["user-info"][0]
@@ -35,6 +37,8 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements vs
3537
private settingsService: SettingsService | null = null
3638
private telemetryClient: TelemetryClient | null = null
3739
private shareService: ShareService | null = null
40+
private connectionMonitor: ConnectionMonitor | null = null
41+
private queueManager: TelemetryQueueManager | null = null
3842
private isInitialized = false
3943
private log: (...args: unknown[]) => void
4044

@@ -87,9 +91,60 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements vs
8791
this.settingsService = cloudSettingsService
8892
}
8993

90-
this.telemetryClient = new TelemetryClient(this.authService, this.settingsService)
94+
this.telemetryClient = new TelemetryClient(this.authService, this.settingsService, false, this.log)
9195
this.shareService = new ShareService(this.authService, this.settingsService, this.log)
9296

97+
// Initialize connection monitor and queue manager
98+
this.connectionMonitor = new ConnectionMonitor()
99+
this.queueManager = TelemetryQueueManager.getInstance()
100+
101+
// Check if telemetry queue is enabled
102+
let isQueueEnabled = true
103+
try {
104+
const { ContextProxy } = await import("../../../src/core/config/ContextProxy")
105+
isQueueEnabled = ContextProxy.instance.getValue("telemetryQueueEnabled") ?? true
106+
} catch (_error) {
107+
// Default to enabled if we can't access settings
108+
this.log("[CloudService] Could not access telemetryQueueEnabled setting, defaulting to enabled")
109+
}
110+
111+
if (isQueueEnabled) {
112+
// Set up connection monitoring with debouncing
113+
let connectionRestoredDebounceTimer: NodeJS.Timeout | null = null
114+
const connectionRestoredDebounceDelay = 3000 // 3 seconds
115+
116+
this.connectionMonitor.onConnectionRestored(() => {
117+
this.log("[CloudService] Connection restored, scheduling queue processing")
118+
119+
// Clear any existing timer
120+
if (connectionRestoredDebounceTimer) {
121+
clearTimeout(connectionRestoredDebounceTimer)
122+
}
123+
124+
// Schedule queue processing with debounce
125+
connectionRestoredDebounceTimer = setTimeout(() => {
126+
this.queueManager
127+
?.processQueue()
128+
.then(() => {
129+
this.log(
130+
"[CloudService] Successfully processed queued events after connection restored",
131+
)
132+
})
133+
.catch((error) => {
134+
this.log("[CloudService] Error processing queue after connection restored:", error)
135+
// Could implement retry logic here if needed in the future
136+
})
137+
}, connectionRestoredDebounceDelay)
138+
})
139+
140+
// Start monitoring if authenticated
141+
if (this.authService.isAuthenticated()) {
142+
this.connectionMonitor.startMonitoring()
143+
}
144+
} else {
145+
this.log("[CloudService] Telemetry queue is disabled")
146+
}
147+
93148
try {
94149
TelemetryService.instance.register(this.telemetryClient)
95150
} catch (error) {
@@ -222,6 +277,34 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements vs
222277
return this.shareService!.canShareTask()
223278
}
224279

280+
// Connection Status
281+
282+
public isOnline(): boolean {
283+
this.ensureInitialized()
284+
return this.connectionMonitor?.getConnectionStatus() ?? true
285+
}
286+
287+
public onConnectionRestored(callback: () => void): void {
288+
this.ensureInitialized()
289+
if (this.connectionMonitor) {
290+
this.connectionMonitor.onConnectionRestored(callback)
291+
}
292+
}
293+
294+
public onConnectionLost(callback: () => void): void {
295+
this.ensureInitialized()
296+
if (this.connectionMonitor) {
297+
this.connectionMonitor.onConnectionLost(callback)
298+
}
299+
}
300+
301+
public removeConnectionListener(event: "connection-restored" | "connection-lost", callback: () => void): void {
302+
this.ensureInitialized()
303+
if (this.connectionMonitor) {
304+
this.connectionMonitor.removeListener(event, callback)
305+
}
306+
}
307+
225308
// Lifecycle
226309

227310
public dispose(): void {
@@ -235,6 +318,9 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements vs
235318
}
236319
this.settingsService.dispose()
237320
}
321+
if (this.connectionMonitor) {
322+
this.connectionMonitor.dispose()
323+
}
238324

239325
this.isInitialized = false
240326
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { EventEmitter } from "events"
2+
import { getRooCodeApiUrl } from "./Config"
3+
4+
export class ConnectionMonitor extends EventEmitter {
5+
private isOnline = true
6+
private checkInterval: NodeJS.Timeout | null = null
7+
private readonly healthCheckEndpoint = "/api/health"
8+
private readonly defaultCheckInterval = 30000 // 30 seconds
9+
10+
constructor() {
11+
super()
12+
}
13+
14+
/**
15+
* Check if the connection to the API is available
16+
*/
17+
public async checkConnection(): Promise<boolean> {
18+
try {
19+
const controller = new AbortController()
20+
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5 second timeout
21+
22+
const response = await fetch(`${getRooCodeApiUrl()}${this.healthCheckEndpoint}`, {
23+
method: "GET",
24+
signal: controller.signal,
25+
})
26+
27+
clearTimeout(timeoutId)
28+
29+
const wasOffline = !this.isOnline
30+
this.isOnline = response.ok
31+
32+
// Emit event if connection status changed from offline to online
33+
if (wasOffline && this.isOnline) {
34+
this.emit("connection-restored")
35+
}
36+
37+
return this.isOnline
38+
} catch (_error) {
39+
const wasOnline = this.isOnline
40+
this.isOnline = false
41+
42+
// Emit event if connection status changed from online to offline
43+
if (wasOnline && !this.isOnline) {
44+
this.emit("connection-lost")
45+
}
46+
47+
return false
48+
}
49+
}
50+
51+
/**
52+
* Get current connection status
53+
*/
54+
public getConnectionStatus(): boolean {
55+
return this.isOnline
56+
}
57+
58+
/**
59+
* Register a callback for when connection is restored
60+
*/
61+
public onConnectionRestored(callback: () => void): void {
62+
this.on("connection-restored", callback)
63+
}
64+
65+
/**
66+
* Register a callback for when connection is lost
67+
*/
68+
public onConnectionLost(callback: () => void): void {
69+
this.on("connection-lost", callback)
70+
}
71+
72+
/**
73+
* Start monitoring the connection
74+
*/
75+
public startMonitoring(intervalMs: number = this.defaultCheckInterval): void {
76+
// Stop any existing monitoring
77+
this.stopMonitoring()
78+
79+
// Initial check
80+
this.checkConnection()
81+
82+
// Set up periodic checks
83+
this.checkInterval = setInterval(() => {
84+
this.checkConnection()
85+
}, intervalMs)
86+
}
87+
88+
/**
89+
* Stop monitoring the connection
90+
*/
91+
public stopMonitoring(): void {
92+
if (this.checkInterval) {
93+
clearInterval(this.checkInterval)
94+
this.checkInterval = null
95+
}
96+
}
97+
98+
/**
99+
* Clean up resources
100+
*/
101+
public dispose(): void {
102+
this.stopMonitoring()
103+
this.removeAllListeners()
104+
}
105+
}

0 commit comments

Comments
 (0)