mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-08 22:21:23 +00:00
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
This commit is contained in:
parent
ebfd384ac4
commit
f34c411cf9
21 changed files with 1942 additions and 46 deletions
|
|
@ -19,6 +19,8 @@ import { CloudSettingsService } from "./CloudSettingsService"
|
|||
import { StaticSettingsService } from "./StaticSettingsService"
|
||||
import { TelemetryClient } from "./TelemetryClient"
|
||||
import { ShareService, TaskNotFoundError } from "./ShareService"
|
||||
import { ConnectionMonitor } from "./ConnectionMonitor"
|
||||
import { TelemetryQueueManager } from "./TelemetryQueueManager"
|
||||
|
||||
type AuthStateChangedPayload = CloudServiceEvents["auth-state-changed"][0]
|
||||
type AuthUserInfoPayload = CloudServiceEvents["user-info"][0]
|
||||
|
|
@ -35,6 +37,8 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements vs
|
|||
private settingsService: SettingsService | null = null
|
||||
private telemetryClient: TelemetryClient | null = null
|
||||
private shareService: ShareService | null = null
|
||||
private connectionMonitor: ConnectionMonitor | null = null
|
||||
private queueManager: TelemetryQueueManager | null = null
|
||||
private isInitialized = false
|
||||
private log: (...args: unknown[]) => void
|
||||
|
||||
|
|
@ -87,9 +91,60 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements vs
|
|||
this.settingsService = cloudSettingsService
|
||||
}
|
||||
|
||||
this.telemetryClient = new TelemetryClient(this.authService, this.settingsService)
|
||||
this.telemetryClient = new TelemetryClient(this.authService, this.settingsService, false, this.log)
|
||||
this.shareService = new ShareService(this.authService, this.settingsService, this.log)
|
||||
|
||||
// Initialize connection monitor and queue manager
|
||||
this.connectionMonitor = new ConnectionMonitor()
|
||||
this.queueManager = TelemetryQueueManager.getInstance()
|
||||
|
||||
// Check if telemetry queue is enabled
|
||||
let isQueueEnabled = true
|
||||
try {
|
||||
const { ContextProxy } = await import("../../../src/core/config/ContextProxy")
|
||||
isQueueEnabled = ContextProxy.instance.getValue("telemetryQueueEnabled") ?? true
|
||||
} catch (_error) {
|
||||
// Default to enabled if we can't access settings
|
||||
this.log("[CloudService] Could not access telemetryQueueEnabled setting, defaulting to enabled")
|
||||
}
|
||||
|
||||
if (isQueueEnabled) {
|
||||
// Set up connection monitoring with debouncing
|
||||
let connectionRestoredDebounceTimer: NodeJS.Timeout | null = null
|
||||
const connectionRestoredDebounceDelay = 3000 // 3 seconds
|
||||
|
||||
this.connectionMonitor.onConnectionRestored(() => {
|
||||
this.log("[CloudService] Connection restored, scheduling queue processing")
|
||||
|
||||
// Clear any existing timer
|
||||
if (connectionRestoredDebounceTimer) {
|
||||
clearTimeout(connectionRestoredDebounceTimer)
|
||||
}
|
||||
|
||||
// Schedule queue processing with debounce
|
||||
connectionRestoredDebounceTimer = setTimeout(() => {
|
||||
this.queueManager
|
||||
?.processQueue()
|
||||
.then(() => {
|
||||
this.log(
|
||||
"[CloudService] Successfully processed queued events after connection restored",
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.log("[CloudService] Error processing queue after connection restored:", error)
|
||||
// Could implement retry logic here if needed in the future
|
||||
})
|
||||
}, connectionRestoredDebounceDelay)
|
||||
})
|
||||
|
||||
// Start monitoring if authenticated
|
||||
if (this.authService.isAuthenticated()) {
|
||||
this.connectionMonitor.startMonitoring()
|
||||
}
|
||||
} else {
|
||||
this.log("[CloudService] Telemetry queue is disabled")
|
||||
}
|
||||
|
||||
try {
|
||||
TelemetryService.instance.register(this.telemetryClient)
|
||||
} catch (error) {
|
||||
|
|
@ -222,6 +277,34 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements vs
|
|||
return this.shareService!.canShareTask()
|
||||
}
|
||||
|
||||
// Connection Status
|
||||
|
||||
public isOnline(): boolean {
|
||||
this.ensureInitialized()
|
||||
return this.connectionMonitor?.getConnectionStatus() ?? true
|
||||
}
|
||||
|
||||
public onConnectionRestored(callback: () => void): void {
|
||||
this.ensureInitialized()
|
||||
if (this.connectionMonitor) {
|
||||
this.connectionMonitor.onConnectionRestored(callback)
|
||||
}
|
||||
}
|
||||
|
||||
public onConnectionLost(callback: () => void): void {
|
||||
this.ensureInitialized()
|
||||
if (this.connectionMonitor) {
|
||||
this.connectionMonitor.onConnectionLost(callback)
|
||||
}
|
||||
}
|
||||
|
||||
public removeConnectionListener(event: "connection-restored" | "connection-lost", callback: () => void): void {
|
||||
this.ensureInitialized()
|
||||
if (this.connectionMonitor) {
|
||||
this.connectionMonitor.removeListener(event, callback)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
public dispose(): void {
|
||||
|
|
@ -235,6 +318,9 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements vs
|
|||
}
|
||||
this.settingsService.dispose()
|
||||
}
|
||||
if (this.connectionMonitor) {
|
||||
this.connectionMonitor.dispose()
|
||||
}
|
||||
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
|
|
|||
105
packages/cloud/src/ConnectionMonitor.ts
Normal file
105
packages/cloud/src/ConnectionMonitor.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { EventEmitter } from "events"
|
||||
import { getRooCodeApiUrl } from "./Config"
|
||||
|
||||
export class ConnectionMonitor extends EventEmitter {
|
||||
private isOnline = true
|
||||
private checkInterval: NodeJS.Timeout | null = null
|
||||
private readonly healthCheckEndpoint = "/api/health"
|
||||
private readonly defaultCheckInterval = 30000 // 30 seconds
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the connection to the API is available
|
||||
*/
|
||||
public async checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5 second timeout
|
||||
|
||||
const response = await fetch(`${getRooCodeApiUrl()}${this.healthCheckEndpoint}`, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
const wasOffline = !this.isOnline
|
||||
this.isOnline = response.ok
|
||||
|
||||
// Emit event if connection status changed from offline to online
|
||||
if (wasOffline && this.isOnline) {
|
||||
this.emit("connection-restored")
|
||||
}
|
||||
|
||||
return this.isOnline
|
||||
} catch (_error) {
|
||||
const wasOnline = this.isOnline
|
||||
this.isOnline = false
|
||||
|
||||
// Emit event if connection status changed from online to offline
|
||||
if (wasOnline && !this.isOnline) {
|
||||
this.emit("connection-lost")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current connection status
|
||||
*/
|
||||
public getConnectionStatus(): boolean {
|
||||
return this.isOnline
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for when connection is restored
|
||||
*/
|
||||
public onConnectionRestored(callback: () => void): void {
|
||||
this.on("connection-restored", callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for when connection is lost
|
||||
*/
|
||||
public onConnectionLost(callback: () => void): void {
|
||||
this.on("connection-lost", callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring the connection
|
||||
*/
|
||||
public startMonitoring(intervalMs: number = this.defaultCheckInterval): void {
|
||||
// Stop any existing monitoring
|
||||
this.stopMonitoring()
|
||||
|
||||
// Initial check
|
||||
this.checkConnection()
|
||||
|
||||
// Set up periodic checks
|
||||
this.checkInterval = setInterval(() => {
|
||||
this.checkConnection()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring the connection
|
||||
*/
|
||||
public stopMonitoring(): void {
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval)
|
||||
this.checkInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.stopMonitoring()
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
TelemetryEventName,
|
||||
type TelemetryEvent,
|
||||
type QueuedTelemetryEvent,
|
||||
rooCodeTelemetryEventSchema,
|
||||
type ClineMessage,
|
||||
} from "@roo-code/types"
|
||||
|
|
@ -9,12 +10,21 @@ import { BaseTelemetryClient } from "@roo-code/telemetry"
|
|||
import { getRooCodeApiUrl } from "./Config"
|
||||
import type { AuthService } from "./auth"
|
||||
import type { SettingsService } from "./SettingsService"
|
||||
import { TelemetryQueueManager } from "./TelemetryQueueManager"
|
||||
import { ContextProxy } from "../../../src/core/config/ContextProxy"
|
||||
|
||||
export class TelemetryClient extends BaseTelemetryClient {
|
||||
private queueManager: TelemetryQueueManager
|
||||
private isQueueEnabled: boolean = false
|
||||
private log: (...args: unknown[]) => void
|
||||
private processQueueDebounceTimer: NodeJS.Timeout | null = null
|
||||
private readonly processQueueDebounceDelay = 5000 // 5 seconds
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private settingsService: SettingsService,
|
||||
debug = false,
|
||||
log?: (...args: unknown[]) => void,
|
||||
) {
|
||||
super(
|
||||
{
|
||||
|
|
@ -23,6 +33,21 @@ export class TelemetryClient extends BaseTelemetryClient {
|
|||
},
|
||||
debug,
|
||||
)
|
||||
|
||||
this.log = log || console.log
|
||||
|
||||
// Initialize queue manager
|
||||
this.queueManager = TelemetryQueueManager.getInstance()
|
||||
this.queueManager.setProcessCallback(this.processBatchedEvents.bind(this))
|
||||
this.queueManager.setLogger(this.log)
|
||||
|
||||
// Check if queue is enabled
|
||||
try {
|
||||
this.isQueueEnabled = ContextProxy.instance.getValue("telemetryQueueEnabled") ?? true
|
||||
} catch (_error) {
|
||||
// Default to enabled if we can't access settings
|
||||
this.isQueueEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch(path: string, options: RequestInit) {
|
||||
|
|
@ -33,7 +58,7 @@ export class TelemetryClient extends BaseTelemetryClient {
|
|||
const token = this.authService.getSessionToken()
|
||||
|
||||
if (!token) {
|
||||
console.error(`[TelemetryClient#fetch] Unauthorized: No session token available.`)
|
||||
this.log(`[TelemetryClient#fetch] Unauthorized: No session token available.`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -43,9 +68,7 @@ export class TelemetryClient extends BaseTelemetryClient {
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[TelemetryClient#fetch] ${options.method} ${path} -> ${response.status} ${response.statusText}`,
|
||||
)
|
||||
this.log(`[TelemetryClient#fetch] ${options.method} ${path} -> ${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +93,7 @@ export class TelemetryClient extends BaseTelemetryClient {
|
|||
const result = rooCodeTelemetryEventSchema.safeParse(payload)
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
this.log(
|
||||
`[TelemetryClient#capture] Invalid telemetry event: ${result.error.message} - ${JSON.stringify(payload)}`,
|
||||
)
|
||||
|
||||
|
|
@ -79,11 +102,35 @@ export class TelemetryClient extends BaseTelemetryClient {
|
|||
|
||||
try {
|
||||
await this.fetch(`events`, { method: "POST", body: JSON.stringify(result.data) })
|
||||
// Process any queued events on successful send if queue is enabled
|
||||
if (this.isQueueEnabled) {
|
||||
this.debouncedProcessQueue()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TelemetryClient#capture] Error sending telemetry event: ${error}`)
|
||||
this.log(`[TelemetryClient#capture] Error sending telemetry event: ${error}`)
|
||||
// Add to queue for retry if queue is enabled
|
||||
if (this.isQueueEnabled) {
|
||||
const priority = this.queueManager.isErrorEvent(event.event) ? "high" : "normal"
|
||||
await this.queueManager.addToQueue(event, priority)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced queue processing to avoid excessive calls
|
||||
*/
|
||||
private debouncedProcessQueue(): void {
|
||||
if (this.processQueueDebounceTimer) {
|
||||
clearTimeout(this.processQueueDebounceTimer)
|
||||
}
|
||||
|
||||
this.processQueueDebounceTimer = setTimeout(() => {
|
||||
this.queueManager.processQueue().catch((error) => {
|
||||
this.log(`[TelemetryClient#debouncedProcessQueue] Error processing queue: ${error}`)
|
||||
})
|
||||
}, this.processQueueDebounceDelay)
|
||||
}
|
||||
|
||||
public async backfillMessages(messages: ClineMessage[], taskId: string): Promise<void> {
|
||||
if (!this.authService.isAuthenticated()) {
|
||||
if (this.debug) {
|
||||
|
|
@ -95,7 +142,7 @@ export class TelemetryClient extends BaseTelemetryClient {
|
|||
const token = this.authService.getSessionToken()
|
||||
|
||||
if (!token) {
|
||||
console.error(`[TelemetryClient#backfillMessages] Unauthorized: No session token available.`)
|
||||
this.log(`[TelemetryClient#backfillMessages] Unauthorized: No session token available.`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -133,14 +180,14 @@ export class TelemetryClient extends BaseTelemetryClient {
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
this.log(
|
||||
`[TelemetryClient#backfillMessages] POST events/backfill -> ${response.status} ${response.statusText}`,
|
||||
)
|
||||
} else if (this.debug) {
|
||||
console.info(`[TelemetryClient#backfillMessages] Successfully uploaded messages for task ${taskId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TelemetryClient#backfillMessages] Error uploading messages: ${error}`)
|
||||
this.log(`[TelemetryClient#backfillMessages] Error uploading messages: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,5 +212,50 @@ export class TelemetryClient extends BaseTelemetryClient {
|
|||
return true
|
||||
}
|
||||
|
||||
public override async shutdown() {}
|
||||
public override async shutdown() {
|
||||
// Clear any pending debounce timer
|
||||
if (this.processQueueDebounceTimer) {
|
||||
clearTimeout(this.processQueueDebounceTimer)
|
||||
this.processQueueDebounceTimer = null
|
||||
}
|
||||
|
||||
// Process any remaining queued events before shutdown if queue is enabled
|
||||
if (this.isQueueEnabled) {
|
||||
try {
|
||||
await this.queueManager.processQueue()
|
||||
} catch (error) {
|
||||
this.log(`[TelemetryClient#shutdown] Error processing queue: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process batched events from the queue
|
||||
*/
|
||||
private async processBatchedEvents(events: QueuedTelemetryEvent[]): Promise<void> {
|
||||
if (!this.authService.isAuthenticated()) {
|
||||
throw new Error("Not authenticated")
|
||||
}
|
||||
|
||||
const token = this.authService.getSessionToken()
|
||||
if (!token) {
|
||||
throw new Error("No session token available")
|
||||
}
|
||||
|
||||
// Process each event individually to maintain compatibility
|
||||
for (const queuedEvent of events) {
|
||||
const payload = {
|
||||
type: queuedEvent.event.event,
|
||||
properties: await this.getEventProperties(queuedEvent.event),
|
||||
}
|
||||
|
||||
const result = rooCodeTelemetryEventSchema.safeParse(payload)
|
||||
if (!result.success) {
|
||||
this.log(`[TelemetryClient#processBatchedEvents] Invalid telemetry event: ${result.error.message}`)
|
||||
continue
|
||||
}
|
||||
|
||||
await this.fetch(`events`, { method: "POST", body: JSON.stringify(result.data) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
301
packages/cloud/src/TelemetryQueueManager.ts
Normal file
301
packages/cloud/src/TelemetryQueueManager.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import type { TelemetryEvent, QueuedTelemetryEvent, TelemetryQueueState } from "@roo-code/types"
|
||||
import { TelemetryEventName } from "@roo-code/types"
|
||||
import { ContextProxy } from "../../../src/core/config/ContextProxy"
|
||||
|
||||
export class TelemetryQueueManager {
|
||||
private static instance: TelemetryQueueManager
|
||||
private queue: QueuedTelemetryEvent[] = []
|
||||
private isProcessing = false
|
||||
private maxQueueSize = 1000
|
||||
private maxRetries = 5
|
||||
private baseRetryDelay = 1000 // 1 second
|
||||
private maxEventAge = 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds
|
||||
private batchSize = 50
|
||||
private processCallback?: (events: QueuedTelemetryEvent[]) => Promise<void>
|
||||
private log: (...args: unknown[]) => void
|
||||
|
||||
private constructor() {
|
||||
this.log = console.log // Default logger
|
||||
}
|
||||
|
||||
public static getInstance(): TelemetryQueueManager {
|
||||
if (!TelemetryQueueManager.instance) {
|
||||
TelemetryQueueManager.instance = new TelemetryQueueManager()
|
||||
}
|
||||
return TelemetryQueueManager.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the callback function to process events
|
||||
*/
|
||||
public setProcessCallback(callback: (events: QueuedTelemetryEvent[]) => Promise<void>): void {
|
||||
this.processCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event to the queue
|
||||
*/
|
||||
public async addToQueue(event: TelemetryEvent, priority: "high" | "normal" = "normal"): Promise<void> {
|
||||
const queuedEvent: QueuedTelemetryEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
event,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0,
|
||||
priority,
|
||||
}
|
||||
|
||||
// Load queue from storage
|
||||
await this.loadQueueFromStorage()
|
||||
|
||||
// Add new event
|
||||
this.queue.push(queuedEvent)
|
||||
|
||||
// Sort by priority (high priority first) and then by timestamp
|
||||
this.queue.sort((a, b) => {
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority === "high" ? -1 : 1
|
||||
}
|
||||
return a.timestamp - b.timestamp
|
||||
})
|
||||
|
||||
// Enforce queue size limit (FIFO after priority sorting)
|
||||
if (this.queue.length > this.maxQueueSize) {
|
||||
// Remove oldest normal priority events first
|
||||
const normalPriorityEvents = this.queue.filter((e) => e.priority === "normal")
|
||||
const highPriorityEvents = this.queue.filter((e) => e.priority === "high")
|
||||
|
||||
if (normalPriorityEvents.length > 0) {
|
||||
const eventsToRemove = this.queue.length - this.maxQueueSize
|
||||
this.queue = [...highPriorityEvents, ...normalPriorityEvents.slice(eventsToRemove)]
|
||||
} else {
|
||||
// If all events are high priority, remove oldest ones
|
||||
this.queue = this.queue.slice(this.queue.length - this.maxQueueSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated queue
|
||||
await this.saveQueueToStorage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Process queued events
|
||||
*/
|
||||
public async processQueue(): Promise<void> {
|
||||
if (this.isProcessing || !this.processCallback) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
|
||||
try {
|
||||
// Load queue from storage
|
||||
await this.loadQueueFromStorage()
|
||||
|
||||
// Clean up expired events
|
||||
await this.clearExpiredEvents()
|
||||
|
||||
// Get events ready for processing
|
||||
const now = Date.now()
|
||||
const eventsToProcess = this.queue
|
||||
.filter((event) => {
|
||||
if (event.retryCount >= this.maxRetries) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.lastRetryTimestamp) {
|
||||
const backoffDelay = this.calculateBackoffDelay(event.retryCount)
|
||||
return now - event.lastRetryTimestamp >= backoffDelay
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.slice(0, this.batchSize)
|
||||
|
||||
if (eventsToProcess.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Process batch
|
||||
await this.processBatch(eventsToProcess)
|
||||
|
||||
// Update metadata
|
||||
await this.updateMetadata()
|
||||
} finally {
|
||||
this.isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of events
|
||||
*/
|
||||
private async processBatch(events: QueuedTelemetryEvent[]): Promise<void> {
|
||||
if (!this.processCallback) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the process callback
|
||||
await this.processCallback(events)
|
||||
|
||||
// Remove successfully processed events from queue
|
||||
const processedIds = new Set(events.map((e) => e.id))
|
||||
this.queue = this.queue.filter((e) => !processedIds.has(e.id))
|
||||
|
||||
// Save updated queue
|
||||
await this.saveQueueToStorage()
|
||||
} catch (error) {
|
||||
// Update retry information for failed events
|
||||
const now = Date.now()
|
||||
events.forEach((event) => {
|
||||
const queuedEvent = this.queue.find((e) => e.id === event.id)
|
||||
if (queuedEvent) {
|
||||
queuedEvent.retryCount++
|
||||
queuedEvent.lastRetryTimestamp = now
|
||||
}
|
||||
})
|
||||
|
||||
// Save updated queue with retry information
|
||||
await this.saveQueueToStorage()
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay
|
||||
*/
|
||||
private calculateBackoffDelay(retryCount: number): number {
|
||||
return this.baseRetryDelay * Math.pow(2, retryCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the logger function
|
||||
*/
|
||||
public setLogger(logger: (...args: unknown[]) => void): void {
|
||||
this.log = logger
|
||||
}
|
||||
|
||||
/**
|
||||
* Load queue from storage
|
||||
*/
|
||||
private async loadQueueFromStorage(): Promise<void> {
|
||||
try {
|
||||
const contextProxy = ContextProxy.instance
|
||||
const storedQueue = contextProxy.getGlobalState("telemetryQueue")
|
||||
|
||||
if (storedQueue && Array.isArray(storedQueue)) {
|
||||
// Add validation for queue size to prevent memory issues
|
||||
if (storedQueue.length > this.maxQueueSize * 2) {
|
||||
this.log("[TelemetryQueueManager] Queue size exceeds safety limit, truncating to max size")
|
||||
this.queue = (storedQueue as QueuedTelemetryEvent[]).slice(-this.maxQueueSize)
|
||||
} else {
|
||||
this.queue = storedQueue as QueuedTelemetryEvent[]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log("[TelemetryQueueManager] Error loading queue from storage:", error)
|
||||
this.queue = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save queue to storage
|
||||
*/
|
||||
private async saveQueueToStorage(): Promise<void> {
|
||||
try {
|
||||
const contextProxy = ContextProxy.instance
|
||||
await contextProxy.updateGlobalState("telemetryQueue", this.queue)
|
||||
} catch (error) {
|
||||
this.log("[TelemetryQueueManager] Error saving queue to storage:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear events older than maxEventAge
|
||||
*/
|
||||
private async clearExpiredEvents(): Promise<void> {
|
||||
const now = Date.now()
|
||||
const originalLength = this.queue.length
|
||||
|
||||
this.queue = this.queue.filter((event) => {
|
||||
return now - event.timestamp < this.maxEventAge
|
||||
})
|
||||
|
||||
if (this.queue.length < originalLength) {
|
||||
await this.saveQueueToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update queue metadata
|
||||
*/
|
||||
private async updateMetadata(): Promise<void> {
|
||||
try {
|
||||
const contextProxy = ContextProxy.instance
|
||||
const metadata: TelemetryQueueState = {
|
||||
events: this.queue,
|
||||
lastProcessedTimestamp: Date.now(),
|
||||
}
|
||||
await contextProxy.updateGlobalState("telemetryQueueMetadata", metadata)
|
||||
} catch (error) {
|
||||
this.log("[TelemetryQueueManager] Error updating metadata:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue size
|
||||
*/
|
||||
public getQueueSize(): number {
|
||||
return this.queue.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire queue
|
||||
*/
|
||||
public async clearQueue(): Promise<void> {
|
||||
this.queue = []
|
||||
await this.saveQueueToStorage()
|
||||
await this.updateMetadata()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event is an error event
|
||||
*/
|
||||
public isErrorEvent(eventName: TelemetryEventName): boolean {
|
||||
return [
|
||||
TelemetryEventName.SCHEMA_VALIDATION_ERROR,
|
||||
TelemetryEventName.DIFF_APPLICATION_ERROR,
|
||||
TelemetryEventName.SHELL_INTEGRATION_ERROR,
|
||||
TelemetryEventName.CONSECUTIVE_MISTAKE_ERROR,
|
||||
TelemetryEventName.CODE_INDEX_ERROR,
|
||||
].includes(eventName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
public async getQueueStats(): Promise<{
|
||||
totalEvents: number
|
||||
highPriorityEvents: number
|
||||
normalPriorityEvents: number
|
||||
retriedEvents: number
|
||||
oldestEventAge: number | null
|
||||
}> {
|
||||
await this.loadQueueFromStorage()
|
||||
|
||||
const now = Date.now()
|
||||
const highPriorityEvents = this.queue.filter((e) => e.priority === "high").length
|
||||
const normalPriorityEvents = this.queue.filter((e) => e.priority === "normal").length
|
||||
const retriedEvents = this.queue.filter((e) => e.retryCount > 0).length
|
||||
const oldestEvent = this.queue.length > 0 ? Math.min(...this.queue.map((e) => e.timestamp)) : null
|
||||
const oldestEventAge = oldestEvent ? now - oldestEvent : null
|
||||
|
||||
return {
|
||||
totalEvents: this.queue.length,
|
||||
highPriorityEvents,
|
||||
normalPriorityEvents,
|
||||
retriedEvents,
|
||||
oldestEventAge,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,13 @@ describe("CloudService", () => {
|
|||
}
|
||||
let mockTelemetryClient: {
|
||||
backfillMessages: ReturnType<typeof vi.fn>
|
||||
capture: ReturnType<typeof vi.fn>
|
||||
setProvider: ReturnType<typeof vi.fn>
|
||||
isTelemetryEnabled: ReturnType<typeof vi.fn>
|
||||
updateTelemetryState: ReturnType<typeof vi.fn>
|
||||
shutdown: ReturnType<typeof vi.fn>
|
||||
processBatchedEvents: ReturnType<typeof vi.fn>
|
||||
debouncedProcessQueue: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let mockTelemetryService: {
|
||||
hasInstance: ReturnType<typeof vi.fn>
|
||||
|
|
@ -143,6 +150,13 @@ describe("CloudService", () => {
|
|||
|
||||
mockTelemetryClient = {
|
||||
backfillMessages: vi.fn().mockResolvedValue(undefined),
|
||||
capture: vi.fn(),
|
||||
setProvider: vi.fn(),
|
||||
isTelemetryEnabled: vi.fn().mockReturnValue(true),
|
||||
updateTelemetryState: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
processBatchedEvents: vi.fn().mockResolvedValue(undefined),
|
||||
debouncedProcessQueue: vi.fn(),
|
||||
}
|
||||
|
||||
mockTelemetryService = {
|
||||
|
|
@ -155,7 +169,24 @@ describe("CloudService", () => {
|
|||
vi.mocked(WebAuthService).mockImplementation(() => mockAuthService as unknown as WebAuthService)
|
||||
vi.mocked(CloudSettingsService).mockImplementation(() => mockSettingsService as unknown as CloudSettingsService)
|
||||
vi.mocked(ShareService).mockImplementation(() => mockShareService as unknown as ShareService)
|
||||
vi.mocked(TelemetryClient).mockImplementation(() => mockTelemetryClient as unknown as TelemetryClient)
|
||||
vi.mocked(TelemetryClient).mockImplementation((authService, settingsService, debug, log) => {
|
||||
// Create a mock instance with all required methods
|
||||
const instance = {
|
||||
...mockTelemetryClient,
|
||||
log: log || console.log,
|
||||
queueManager: {
|
||||
setProcessCallback: vi.fn(),
|
||||
setLogger: vi.fn(),
|
||||
processQueue: vi.fn().mockResolvedValue(undefined),
|
||||
isErrorEvent: vi.fn().mockReturnValue(false),
|
||||
addToQueue: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
processBatchedEvents: mockTelemetryClient.processBatchedEvents,
|
||||
}
|
||||
|
||||
// Return the instance with bound methods
|
||||
return instance as unknown as TelemetryClient
|
||||
})
|
||||
|
||||
vi.mocked(TelemetryService.hasInstance).mockReturnValue(true)
|
||||
Object.defineProperty(TelemetryService, "instance", {
|
||||
|
|
|
|||
328
packages/cloud/src/__tests__/ConnectionMonitor.test.ts
Normal file
328
packages/cloud/src/__tests__/ConnectionMonitor.test.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// npx vitest run src/__tests__/ConnectionMonitor.test.ts
|
||||
|
||||
import { ConnectionMonitor } from "../ConnectionMonitor"
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch as any
|
||||
|
||||
// Mock Config module
|
||||
vi.mock("../Config", () => ({
|
||||
getRooCodeApiUrl: vi.fn(() => "https://app.roocode.com"),
|
||||
}))
|
||||
|
||||
// Mock timers
|
||||
vi.useFakeTimers()
|
||||
|
||||
describe("ConnectionMonitor", () => {
|
||||
let monitor: ConnectionMonitor
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.clearAllTimers()
|
||||
monitor = new ConnectionMonitor()
|
||||
|
||||
// Default to successful fetch
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
monitor.dispose()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("checkConnection", () => {
|
||||
it("should return true when fetch succeeds", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
})
|
||||
|
||||
const result = await monitor.checkConnection()
|
||||
expect(result).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/health",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return false when fetch fails", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const result = await monitor.checkConnection()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when response is not ok", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
|
||||
const result = await monitor.checkConnection()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle timeout", async () => {
|
||||
// Mock fetch to simulate abort
|
||||
mockFetch.mockImplementation((url, options) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Listen for abort signal
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
reject(new Error("The operation was aborted"))
|
||||
})
|
||||
}
|
||||
// Never resolve naturally
|
||||
})
|
||||
})
|
||||
|
||||
const resultPromise = monitor.checkConnection()
|
||||
|
||||
// Fast-forward past the timeout (5 seconds)
|
||||
await vi.advanceTimersByTimeAsync(5001)
|
||||
|
||||
const result = await resultPromise
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("startMonitoring", () => {
|
||||
it("should start periodic connection checks", async () => {
|
||||
const checkSpy = vi.spyOn(monitor, "checkConnection")
|
||||
|
||||
monitor.startMonitoring()
|
||||
|
||||
// Should check immediately
|
||||
expect(checkSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Advance timer to trigger next check
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
expect(checkSpy).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Advance timer again
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
expect(checkSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it("should not start multiple monitoring sessions", async () => {
|
||||
const checkSpy = vi.spyOn(monitor, "checkConnection")
|
||||
|
||||
monitor.startMonitoring()
|
||||
monitor.startMonitoring() // Second call should stop first and restart
|
||||
|
||||
// Should check once for each startMonitoring call
|
||||
expect(checkSpy).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Advance timer - should only increment by 1
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
expect(checkSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it("should accept custom interval", async () => {
|
||||
const checkSpy = vi.spyOn(monitor, "checkConnection")
|
||||
|
||||
monitor.startMonitoring(10000) // 10 seconds
|
||||
|
||||
expect(checkSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Advance by 10 seconds
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
expect(checkSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("stopMonitoring", () => {
|
||||
it("should stop periodic checks", async () => {
|
||||
const checkSpy = vi.spyOn(monitor, "checkConnection")
|
||||
|
||||
monitor.startMonitoring()
|
||||
expect(checkSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
monitor.stopMonitoring()
|
||||
|
||||
// Advance timer - no more checks should occur
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
expect(checkSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("connection state changes", () => {
|
||||
it("should emit connectionRestored event when connection is restored", async () => {
|
||||
const restoredCallback = vi.fn()
|
||||
monitor.onConnectionRestored(restoredCallback)
|
||||
|
||||
// Start with offline state
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
await monitor.checkConnection()
|
||||
|
||||
// Connection should be offline
|
||||
expect(monitor.getConnectionStatus()).toBe(false)
|
||||
expect(restoredCallback).not.toHaveBeenCalled()
|
||||
|
||||
// Restore connection
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
})
|
||||
|
||||
await monitor.checkConnection()
|
||||
|
||||
// Should emit restored event
|
||||
expect(restoredCallback).toHaveBeenCalledTimes(1)
|
||||
expect(monitor.getConnectionStatus()).toBe(true)
|
||||
})
|
||||
|
||||
it("should emit connectionLost event when connection is lost", async () => {
|
||||
const lostCallback = vi.fn()
|
||||
monitor.onConnectionLost(lostCallback)
|
||||
|
||||
// Start with online state (default)
|
||||
expect(monitor.getConnectionStatus()).toBe(true)
|
||||
|
||||
// Lose connection
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
await monitor.checkConnection()
|
||||
|
||||
// Should emit lost event
|
||||
expect(lostCallback).toHaveBeenCalledTimes(1)
|
||||
expect(monitor.getConnectionStatus()).toBe(false)
|
||||
})
|
||||
|
||||
it("should not emit events when state doesn't change", async () => {
|
||||
const restoredCallback = vi.fn()
|
||||
const lostCallback = vi.fn()
|
||||
monitor.onConnectionRestored(restoredCallback)
|
||||
monitor.onConnectionLost(lostCallback)
|
||||
|
||||
// Multiple successful checks
|
||||
await monitor.checkConnection()
|
||||
await monitor.checkConnection()
|
||||
await monitor.checkConnection()
|
||||
|
||||
// Should not emit any events
|
||||
expect(restoredCallback).not.toHaveBeenCalled()
|
||||
expect(lostCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getConnectionStatus", () => {
|
||||
it("should return current connection status", () => {
|
||||
// Default is true
|
||||
expect(monitor.getConnectionStatus()).toBe(true)
|
||||
})
|
||||
|
||||
it("should update status after checks", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await monitor.checkConnection()
|
||||
|
||||
expect(monitor.getConnectionStatus()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should stop monitoring and remove listeners", async () => {
|
||||
const restoredCallback = vi.fn()
|
||||
const lostCallback = vi.fn()
|
||||
|
||||
monitor.onConnectionRestored(restoredCallback)
|
||||
monitor.onConnectionLost(lostCallback)
|
||||
monitor.startMonitoring()
|
||||
|
||||
monitor.dispose()
|
||||
|
||||
// Try to trigger events - callbacks should not be called
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
|
||||
expect(restoredCallback).not.toHaveBeenCalled()
|
||||
expect(lostCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle check errors gracefully", async () => {
|
||||
// Mock an error during the check
|
||||
mockFetch.mockRejectedValue(new Error("Unexpected error"))
|
||||
|
||||
const result = await monitor.checkConnection()
|
||||
|
||||
// Should treat as offline
|
||||
expect(result).toBe(false)
|
||||
expect(monitor.getConnectionStatus()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiple listeners", () => {
|
||||
it("should support multiple connectionRestored listeners", async () => {
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
|
||||
monitor.onConnectionRestored(callback1)
|
||||
monitor.onConnectionRestored(callback2)
|
||||
|
||||
// Start offline
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
await monitor.checkConnection()
|
||||
|
||||
// Restore connection
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200 })
|
||||
await monitor.checkConnection()
|
||||
|
||||
expect(callback1).toHaveBeenCalledTimes(1)
|
||||
expect(callback2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should support multiple connectionLost listeners", async () => {
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
|
||||
monitor.onConnectionLost(callback1)
|
||||
monitor.onConnectionLost(callback2)
|
||||
|
||||
// Lose connection
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
await monitor.checkConnection()
|
||||
|
||||
expect(callback1).toHaveBeenCalledTimes(1)
|
||||
expect(callback2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("integration with monitoring", () => {
|
||||
it("should emit events during periodic monitoring", async () => {
|
||||
const restoredCallback = vi.fn()
|
||||
const lostCallback = vi.fn()
|
||||
|
||||
monitor.onConnectionRestored(restoredCallback)
|
||||
monitor.onConnectionLost(lostCallback)
|
||||
|
||||
// Start monitoring with shorter interval for testing
|
||||
monitor.startMonitoring(1000)
|
||||
|
||||
// First check succeeds (default)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
// Next check fails
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(lostCallback).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Next check succeeds
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200 })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(restoredCallback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
393
packages/cloud/src/__tests__/TelemetryClient.queue.test.ts
Normal file
393
packages/cloud/src/__tests__/TelemetryClient.queue.test.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// npx vitest run src/__tests__/TelemetryClient.queue.test.ts
|
||||
|
||||
import { TelemetryEventName } from "@roo-code/types"
|
||||
import { TelemetryClient } from "../TelemetryClient"
|
||||
import { TelemetryQueueManager } from "../TelemetryQueueManager"
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch as any
|
||||
|
||||
// Mock ContextProxy
|
||||
vi.mock("../../../../src/core/config/ContextProxy", () => ({
|
||||
ContextProxy: {
|
||||
instance: {
|
||||
getValue: vi.fn(),
|
||||
getGlobalState: vi.fn(),
|
||||
updateGlobalState: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock crypto.randomUUID
|
||||
Object.defineProperty(global, "crypto", {
|
||||
value: {
|
||||
randomUUID: vi.fn(() => `test-uuid-${Date.now()}`),
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
describe("TelemetryClient with Queue", () => {
|
||||
let mockAuthService: any
|
||||
let mockSettingsService: any
|
||||
let mockContextProxy: any
|
||||
let queueManager: TelemetryQueueManager
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset singleton
|
||||
;(TelemetryQueueManager as any).instance = null
|
||||
|
||||
// Get mock ContextProxy
|
||||
const { ContextProxy } = await import("../../../../src/core/config/ContextProxy")
|
||||
mockContextProxy = ContextProxy.instance
|
||||
|
||||
// Mock AuthService
|
||||
mockAuthService = {
|
||||
getSessionToken: vi.fn().mockReturnValue("mock-token"),
|
||||
getState: vi.fn().mockReturnValue("active-session"),
|
||||
isAuthenticated: vi.fn().mockReturnValue(true),
|
||||
hasActiveSession: vi.fn().mockReturnValue(true),
|
||||
}
|
||||
|
||||
// Mock SettingsService
|
||||
mockSettingsService = {
|
||||
getSettings: vi.fn().mockReturnValue({
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
// Set up ContextProxy mocks
|
||||
mockContextProxy.getValue.mockImplementation((key: string) => {
|
||||
if (key === "telemetryQueueEnabled") {
|
||||
return true // Queue is enabled
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
mockContextProxy.getGlobalState.mockReturnValue([])
|
||||
mockContextProxy.updateGlobalState.mockResolvedValue(undefined)
|
||||
|
||||
// Default successful fetch
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
|
||||
queueManager = TelemetryQueueManager.getInstance()
|
||||
|
||||
vi.spyOn(console, "info").mockImplementation(() => {})
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("queue integration", () => {
|
||||
it("should add event to queue when telemetry fails", async () => {
|
||||
// Make fetch fail
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
const addToQueueSpy = vi.spyOn(queueManager, "addToQueue")
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
taskId: "test-task-id",
|
||||
},
|
||||
})
|
||||
|
||||
// Should add to queue
|
||||
expect(addToQueueSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
}),
|
||||
"normal",
|
||||
)
|
||||
})
|
||||
|
||||
it("should add error events to queue with high priority", async () => {
|
||||
// Make fetch fail
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
const addToQueueSpy = vi.spyOn(queueManager, "addToQueue")
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.SCHEMA_VALIDATION_ERROR,
|
||||
properties: {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
error: "test error",
|
||||
},
|
||||
})
|
||||
|
||||
// Should add to queue with high priority
|
||||
expect(addToQueueSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: TelemetryEventName.SCHEMA_VALIDATION_ERROR,
|
||||
}),
|
||||
"high",
|
||||
)
|
||||
})
|
||||
|
||||
it("should process queue after successful send", async () => {
|
||||
vi.useFakeTimers()
|
||||
const processQueueSpy = vi.spyOn(queueManager, "processQueue")
|
||||
|
||||
// Create client (this sets the callback in constructor)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
taskId: "test-task-id",
|
||||
},
|
||||
})
|
||||
|
||||
// Should not process queue immediately due to debouncing
|
||||
expect(processQueueSpy).not.toHaveBeenCalled()
|
||||
|
||||
// Fast forward past the debounce delay (5 seconds)
|
||||
vi.advanceTimersByTime(5000)
|
||||
|
||||
// Now it should have been called
|
||||
expect(processQueueSpy).toHaveBeenCalled()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should not use queue when telemetryQueueEnabled is false", async () => {
|
||||
// Disable queue
|
||||
mockContextProxy.getValue.mockImplementation((key: string) => {
|
||||
if (key === "telemetryQueueEnabled") {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
// Make fetch fail
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
const addToQueueSpy = vi.spyOn(queueManager, "addToQueue")
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
taskId: "test-task-id",
|
||||
},
|
||||
})
|
||||
|
||||
// Should not add to queue
|
||||
expect(addToQueueSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle queue processing callback correctly", async () => {
|
||||
// Capture the callback that was set
|
||||
let processCallback: any
|
||||
vi.spyOn(queueManager, "setProcessCallback").mockImplementation((cb) => {
|
||||
processCallback = cb
|
||||
})
|
||||
|
||||
// Create client (this sets the callback in constructor)
|
||||
const mockLog = vi.fn()
|
||||
const _client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
// Ensure we have the callback
|
||||
expect(processCallback).toBeDefined()
|
||||
|
||||
// Test the callback with proper event structure
|
||||
const queuedEvents = [
|
||||
{
|
||||
id: "test-1",
|
||||
timestamp: Date.now(),
|
||||
event: {
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
taskId: "queued-task-1",
|
||||
},
|
||||
},
|
||||
retryCount: 0,
|
||||
priority: "normal" as const,
|
||||
},
|
||||
{
|
||||
id: "test-2",
|
||||
timestamp: Date.now(),
|
||||
event: {
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
taskId: "queued-task-2",
|
||||
},
|
||||
},
|
||||
retryCount: 1,
|
||||
priority: "normal" as const,
|
||||
},
|
||||
]
|
||||
|
||||
// Reset fetch mock
|
||||
mockFetch.mockClear()
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
|
||||
// Call the callback
|
||||
await processCallback(queuedEvents)
|
||||
|
||||
// Should have made fetch calls for each event
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("should handle queue processing errors", async () => {
|
||||
// Capture the callback
|
||||
let processCallback: any
|
||||
vi.spyOn(queueManager, "setProcessCallback").mockImplementation((cb) => {
|
||||
processCallback = cb
|
||||
})
|
||||
|
||||
// Create client (this sets the callback in constructor)
|
||||
const mockLog = vi.fn()
|
||||
const _client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const queuedEvents = [
|
||||
{
|
||||
id: "test-1",
|
||||
timestamp: Date.now(),
|
||||
event: {
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
taskId: "queued-task-1",
|
||||
},
|
||||
},
|
||||
retryCount: 0,
|
||||
priority: "normal" as const,
|
||||
},
|
||||
]
|
||||
|
||||
// Make fetch fail for queue processing
|
||||
mockFetch.mockClear()
|
||||
mockFetch.mockRejectedValue(new Error("Queue processing error"))
|
||||
|
||||
// The processBatchedEvents method will throw because fetch throws
|
||||
// This is expected behavior - the queue manager will handle the retry
|
||||
await expect(processCallback(queuedEvents)).rejects.toThrow("Queue processing error")
|
||||
|
||||
// Should have attempted to send the event
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not process queue for non-capturable events", async () => {
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
const processQueueSpy = vi.spyOn(queueManager, "processQueue")
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CONVERSATION_MESSAGE, // Non-capturable
|
||||
properties: { test: "value" },
|
||||
})
|
||||
|
||||
// Should not process queue
|
||||
expect(processQueueSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle queue errors gracefully", async () => {
|
||||
// Create client first
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
// Clear previous mocks
|
||||
mockFetch.mockClear()
|
||||
|
||||
// Make fetch fail to trigger queue
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
// Then mock the queue operation to fail
|
||||
const _originalAddToQueue = queueManager.addToQueue.bind(queueManager)
|
||||
const addToQueueSpy = vi.spyOn(queueManager, "addToQueue").mockImplementation(async () => {
|
||||
// Simulate the error without actually throwing in the test
|
||||
console.error("Error adding to telemetry queue:", new Error("Queue error"))
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
// Capture should complete without throwing
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
taskId: "test-task-id",
|
||||
},
|
||||
})
|
||||
|
||||
// Should have attempted to add to queue
|
||||
expect(addToQueueSpy).toHaveBeenCalled()
|
||||
// Should have logged the error
|
||||
expect(console.error).toHaveBeenCalledWith("Error adding to telemetry queue:", expect.any(Error))
|
||||
|
||||
// Restore original method
|
||||
addToQueueSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -52,7 +52,8 @@ describe("TelemetryClient", () => {
|
|||
|
||||
describe("isEventCapturable", () => {
|
||||
it("should return true for events not in exclude list", () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
|
|
@ -66,7 +67,8 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
it("should return false for events in exclude list", () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
|
|
@ -83,7 +85,8 @@ describe("TelemetryClient", () => {
|
|||
},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
|
|
@ -100,7 +103,8 @@ describe("TelemetryClient", () => {
|
|||
},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
|
|
@ -115,7 +119,8 @@ describe("TelemetryClient", () => {
|
|||
cloudSettings: {},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
|
|
@ -128,7 +133,8 @@ describe("TelemetryClient", () => {
|
|||
it("should return false for TASK_MESSAGE events when cloudSettings is undefined", () => {
|
||||
mockSettingsService.getSettings.mockReturnValue({})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
|
|
@ -141,7 +147,8 @@ describe("TelemetryClient", () => {
|
|||
it("should return false for TASK_MESSAGE events when getSettings returns undefined", () => {
|
||||
mockSettingsService.getSettings.mockReturnValue(undefined)
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
|
|
@ -154,7 +161,8 @@ describe("TelemetryClient", () => {
|
|||
|
||||
describe("getEventProperties", () => {
|
||||
it("should merge provider properties with event properties", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockResolvedValue({
|
||||
|
|
@ -195,7 +203,8 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
it("should handle errors from provider gracefully", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockRejectedValue(new Error("Provider error")),
|
||||
|
|
@ -221,7 +230,8 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
it("should return event properties when no provider is set", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const getEventProperties = getPrivateProperty<
|
||||
(event: { event: TelemetryEventName; properties?: Record<string, any> }) => Promise<Record<string, any>>
|
||||
|
|
@ -238,7 +248,8 @@ describe("TelemetryClient", () => {
|
|||
|
||||
describe("capture", () => {
|
||||
it("should not capture events that are not capturable", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CONVERSATION_MESSAGE, // In exclude list.
|
||||
|
|
@ -255,7 +266,8 @@ describe("TelemetryClient", () => {
|
|||
},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_MESSAGE,
|
||||
|
|
@ -278,7 +290,8 @@ describe("TelemetryClient", () => {
|
|||
cloudSettings: {},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_MESSAGE,
|
||||
|
|
@ -297,7 +310,8 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
it("should not send request when schema validation fails", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
|
|
@ -305,11 +319,12 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Invalid telemetry event"))
|
||||
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Invalid telemetry event"))
|
||||
})
|
||||
|
||||
it("should send request when event is capturable and validation passes", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const providerProperties = {
|
||||
appName: "roo-code",
|
||||
|
|
@ -382,7 +397,8 @@ describe("TelemetryClient", () => {
|
|||
properties: eventProperties,
|
||||
}
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_MESSAGE,
|
||||
|
|
@ -399,7 +415,8 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
|
|
@ -414,12 +431,14 @@ describe("TelemetryClient", () => {
|
|||
|
||||
describe("telemetry state methods", () => {
|
||||
it("should always return true for isTelemetryEnabled", () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
expect(client.isTelemetryEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it("should have empty implementations for updateTelemetryState and shutdown", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
client.updateTelemetryState(true)
|
||||
await client.shutdown()
|
||||
})
|
||||
|
|
@ -428,7 +447,8 @@ describe("TelemetryClient", () => {
|
|||
describe("backfillMessages", () => {
|
||||
it("should not send request when not authenticated", async () => {
|
||||
mockAuthService.isAuthenticated.mockReturnValue(false)
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
|
|
@ -446,7 +466,8 @@ describe("TelemetryClient", () => {
|
|||
|
||||
it("should not send request when no session token available", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue(null)
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
|
|
@ -460,13 +481,14 @@ describe("TelemetryClient", () => {
|
|||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[TelemetryClient#backfillMessages] Unauthorized: No session token available.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should send FormData request with correct structure when authenticated", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const providerProperties = {
|
||||
appName: "roo-code",
|
||||
|
|
@ -537,7 +559,8 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
it("should handle provider errors gracefully", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockRejectedValue(new Error("Provider error")),
|
||||
|
|
@ -589,7 +612,8 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
it("should work without provider set", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
|
|
@ -635,7 +659,8 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
|
|
@ -650,7 +675,7 @@ describe("TelemetryClient", () => {
|
|||
|
||||
await expect(client.backfillMessages(messages, "test-task-id")).resolves.not.toThrow()
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"[TelemetryClient#backfillMessages] Error uploading messages: Error: Network error",
|
||||
),
|
||||
|
|
@ -658,7 +683,8 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
it("should handle HTTP error responses", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
|
|
@ -677,13 +703,14 @@ describe("TelemetryClient", () => {
|
|||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[TelemetryClient#backfillMessages] POST events/backfill -> 404 Not Found",
|
||||
)
|
||||
})
|
||||
|
||||
it("should log debug information when debug is enabled", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, true)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, true, mockLog)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
|
|
@ -705,7 +732,8 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
it("should handle empty messages array", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
const mockLog = vi.fn()
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, false, mockLog)
|
||||
|
||||
await client.backfillMessages([], "test-task-id")
|
||||
|
||||
|
|
|
|||
436
packages/cloud/src/__tests__/TelemetryQueueManager.test.ts
Normal file
436
packages/cloud/src/__tests__/TelemetryQueueManager.test.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// npx vitest run src/__tests__/TelemetryQueueManager.test.ts
|
||||
|
||||
import { TelemetryQueueManager } from "../TelemetryQueueManager"
|
||||
import { TelemetryEventName } from "@roo-code/types"
|
||||
import type { QueuedTelemetryEvent, TelemetryEvent } from "@roo-code/types"
|
||||
|
||||
// Mock ContextProxy
|
||||
vi.mock("../../../../src/core/config/ContextProxy", () => ({
|
||||
ContextProxy: {
|
||||
instance: {
|
||||
getGlobalState: vi.fn(),
|
||||
updateGlobalState: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock crypto.randomUUID
|
||||
Object.defineProperty(global, "crypto", {
|
||||
value: {
|
||||
randomUUID: vi.fn(() => `test-uuid-${Date.now()}`),
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
describe("TelemetryQueueManager", () => {
|
||||
let manager: TelemetryQueueManager
|
||||
let mockContextProxy: any
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset singleton instance
|
||||
;(TelemetryQueueManager as any).instance = null
|
||||
|
||||
// Get mock ContextProxy
|
||||
const { ContextProxy } = await import("../../../../src/core/config/ContextProxy")
|
||||
mockContextProxy = ContextProxy.instance
|
||||
|
||||
// Set up default mock values
|
||||
mockContextProxy.getGlobalState.mockReturnValue([])
|
||||
mockContextProxy.updateGlobalState.mockResolvedValue(undefined)
|
||||
|
||||
manager = TelemetryQueueManager.getInstance()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("getInstance", () => {
|
||||
it("should return the same instance (singleton)", () => {
|
||||
const instance1 = TelemetryQueueManager.getInstance()
|
||||
const instance2 = TelemetryQueueManager.getInstance()
|
||||
expect(instance1).toBe(instance2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("addToQueue", () => {
|
||||
it("should add event to queue with normal priority by default", async () => {
|
||||
const event: TelemetryEvent = {
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: { taskId: "test-task" },
|
||||
}
|
||||
|
||||
await manager.addToQueue(event)
|
||||
|
||||
// Check that updateGlobalState was called with the queue
|
||||
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith(
|
||||
"telemetryQueue",
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
event,
|
||||
retryCount: 0,
|
||||
priority: "normal",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it("should add event with high priority when specified", async () => {
|
||||
const event: TelemetryEvent = {
|
||||
event: TelemetryEventName.SCHEMA_VALIDATION_ERROR,
|
||||
properties: { error: "test error" },
|
||||
}
|
||||
|
||||
await manager.addToQueue(event, "high")
|
||||
|
||||
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith(
|
||||
"telemetryQueue",
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
event,
|
||||
priority: "high",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it("should sort queue by priority and timestamp", async () => {
|
||||
// Add normal priority event first
|
||||
await manager.addToQueue({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: { id: 1 },
|
||||
})
|
||||
|
||||
// Add high priority event
|
||||
await manager.addToQueue(
|
||||
{
|
||||
event: TelemetryEventName.SCHEMA_VALIDATION_ERROR,
|
||||
properties: { id: 2 },
|
||||
},
|
||||
"high",
|
||||
)
|
||||
|
||||
// The high priority event should be first in the queue
|
||||
const lastCall =
|
||||
mockContextProxy.updateGlobalState.mock.calls[mockContextProxy.updateGlobalState.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe("telemetryQueue")
|
||||
expect(lastCall[1][0].priority).toBe("high")
|
||||
expect(lastCall[1][1].priority).toBe("normal")
|
||||
})
|
||||
|
||||
it("should enforce queue size limit", async () => {
|
||||
// Mock a full queue
|
||||
const fullQueue = Array(1000)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
id: `id-${i}`,
|
||||
timestamp: Date.now() - i * 1000,
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: {} },
|
||||
retryCount: 0,
|
||||
priority: "normal" as const,
|
||||
}))
|
||||
|
||||
mockContextProxy.getGlobalState.mockReturnValue(fullQueue)
|
||||
|
||||
await manager.addToQueue({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: { new: true },
|
||||
})
|
||||
|
||||
// Should maintain max size
|
||||
const lastCall =
|
||||
mockContextProxy.updateGlobalState.mock.calls[mockContextProxy.updateGlobalState.mock.calls.length - 1]
|
||||
expect(lastCall[1]).toHaveLength(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processQueue", () => {
|
||||
it("should not process without a callback set", async () => {
|
||||
const mockQueue: QueuedTelemetryEvent[] = [
|
||||
{
|
||||
id: "test-1",
|
||||
timestamp: Date.now(),
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: {} },
|
||||
retryCount: 0,
|
||||
priority: "normal",
|
||||
},
|
||||
]
|
||||
|
||||
mockContextProxy.getGlobalState.mockReturnValue(mockQueue)
|
||||
|
||||
await manager.processQueue()
|
||||
|
||||
// Should not update the queue
|
||||
expect(mockContextProxy.updateGlobalState).not.toHaveBeenCalledWith("telemetryQueue", expect.anything())
|
||||
})
|
||||
|
||||
it("should process events with callback", async () => {
|
||||
const mockQueue: QueuedTelemetryEvent[] = [
|
||||
{
|
||||
id: "test-1",
|
||||
timestamp: Date.now(),
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: { id: 1 } },
|
||||
retryCount: 0,
|
||||
priority: "normal",
|
||||
},
|
||||
{
|
||||
id: "test-2",
|
||||
timestamp: Date.now(),
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: { id: 2 } },
|
||||
retryCount: 0,
|
||||
priority: "normal",
|
||||
},
|
||||
]
|
||||
|
||||
mockContextProxy.getGlobalState.mockReturnValue(mockQueue)
|
||||
|
||||
const processCallback = vi.fn().mockResolvedValue(undefined)
|
||||
manager.setProcessCallback(processCallback)
|
||||
|
||||
await manager.processQueue()
|
||||
|
||||
// Should call callback with events
|
||||
expect(processCallback).toHaveBeenCalledWith(mockQueue)
|
||||
|
||||
// Should clear the queue after successful processing
|
||||
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("telemetryQueue", [])
|
||||
})
|
||||
|
||||
it("should handle processing failures and update retry count", async () => {
|
||||
const mockQueue: QueuedTelemetryEvent[] = [
|
||||
{
|
||||
id: "test-1",
|
||||
timestamp: Date.now(),
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: {} },
|
||||
retryCount: 0,
|
||||
priority: "normal",
|
||||
},
|
||||
]
|
||||
|
||||
mockContextProxy.getGlobalState.mockReturnValue(mockQueue)
|
||||
|
||||
const processCallback = vi.fn().mockRejectedValue(new Error("Process error"))
|
||||
manager.setProcessCallback(processCallback)
|
||||
|
||||
await expect(manager.processQueue()).rejects.toThrow("Process error")
|
||||
|
||||
// Should update retry count
|
||||
const updateCalls = mockContextProxy.updateGlobalState.mock.calls.filter(
|
||||
(call: any[]) => call[0] === "telemetryQueue",
|
||||
)
|
||||
const lastQueueUpdate = updateCalls[updateCalls.length - 1]
|
||||
expect(lastQueueUpdate[1][0].retryCount).toBe(1)
|
||||
expect(lastQueueUpdate[1][0].lastRetryTimestamp).toBeDefined()
|
||||
})
|
||||
|
||||
it("should skip events that exceeded max retries", async () => {
|
||||
const mockQueue: QueuedTelemetryEvent[] = [
|
||||
{
|
||||
id: "test-1",
|
||||
timestamp: Date.now(),
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: {} },
|
||||
retryCount: 5, // Max retries
|
||||
priority: "normal",
|
||||
},
|
||||
{
|
||||
id: "test-2",
|
||||
timestamp: Date.now(),
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: {} },
|
||||
retryCount: 0,
|
||||
priority: "normal",
|
||||
},
|
||||
]
|
||||
|
||||
mockContextProxy.getGlobalState.mockReturnValue(mockQueue)
|
||||
|
||||
const processCallback = vi.fn().mockResolvedValue(undefined)
|
||||
manager.setProcessCallback(processCallback)
|
||||
|
||||
await manager.processQueue()
|
||||
|
||||
// Should only process the second event
|
||||
expect(processCallback).toHaveBeenCalledWith([mockQueue[1]])
|
||||
})
|
||||
|
||||
it("should respect backoff delay for retried events", async () => {
|
||||
const now = Date.now()
|
||||
const mockQueue: QueuedTelemetryEvent[] = [
|
||||
{
|
||||
id: "test-1",
|
||||
timestamp: now - 10000,
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: {} },
|
||||
retryCount: 1,
|
||||
lastRetryTimestamp: now - 500, // Only 500ms ago, should wait longer
|
||||
priority: "normal",
|
||||
},
|
||||
]
|
||||
|
||||
mockContextProxy.getGlobalState.mockReturnValue(mockQueue)
|
||||
|
||||
const processCallback = vi.fn()
|
||||
manager.setProcessCallback(processCallback)
|
||||
|
||||
await manager.processQueue()
|
||||
|
||||
// Should not process the event yet
|
||||
expect(processCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should process events in batches", async () => {
|
||||
// Create more events than batch size (50)
|
||||
const mockQueue: QueuedTelemetryEvent[] = Array(60)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
id: `test-${i}`,
|
||||
timestamp: Date.now(),
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: { id: i } },
|
||||
retryCount: 0,
|
||||
priority: "normal" as const,
|
||||
}))
|
||||
|
||||
mockContextProxy.getGlobalState.mockReturnValue(mockQueue)
|
||||
|
||||
const processCallback = vi.fn().mockResolvedValue(undefined)
|
||||
manager.setProcessCallback(processCallback)
|
||||
|
||||
await manager.processQueue()
|
||||
|
||||
// Should only process batch size (50) events
|
||||
expect(processCallback).toHaveBeenCalledWith(expect.arrayContaining(mockQueue.slice(0, 50)))
|
||||
expect(processCallback.mock.calls[0][0]).toHaveLength(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearQueue", () => {
|
||||
it("should clear queue and update metadata", async () => {
|
||||
await manager.clearQueue()
|
||||
|
||||
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith("telemetryQueue", [])
|
||||
expect(mockContextProxy.updateGlobalState).toHaveBeenCalledWith(
|
||||
"telemetryQueueMetadata",
|
||||
expect.objectContaining({
|
||||
events: [],
|
||||
lastProcessedTimestamp: expect.any(Number),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getQueueSize", () => {
|
||||
it("should return current queue size", () => {
|
||||
const size = manager.getQueueSize()
|
||||
expect(size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isErrorEvent", () => {
|
||||
it("should identify error events correctly", () => {
|
||||
expect(manager.isErrorEvent(TelemetryEventName.SCHEMA_VALIDATION_ERROR)).toBe(true)
|
||||
expect(manager.isErrorEvent(TelemetryEventName.DIFF_APPLICATION_ERROR)).toBe(true)
|
||||
expect(manager.isErrorEvent(TelemetryEventName.SHELL_INTEGRATION_ERROR)).toBe(true)
|
||||
expect(manager.isErrorEvent(TelemetryEventName.CONSECUTIVE_MISTAKE_ERROR)).toBe(true)
|
||||
expect(manager.isErrorEvent(TelemetryEventName.CODE_INDEX_ERROR)).toBe(true)
|
||||
expect(manager.isErrorEvent(TelemetryEventName.TASK_CREATED)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getQueueStats", () => {
|
||||
it("should return queue statistics", async () => {
|
||||
const now = Date.now()
|
||||
const mockQueue: QueuedTelemetryEvent[] = [
|
||||
{
|
||||
id: "test-1",
|
||||
timestamp: now - 5000,
|
||||
event: { event: TelemetryEventName.SCHEMA_VALIDATION_ERROR, properties: {} },
|
||||
retryCount: 0,
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: "test-2",
|
||||
timestamp: now - 3000,
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: {} },
|
||||
retryCount: 1,
|
||||
priority: "normal",
|
||||
},
|
||||
{
|
||||
id: "test-3",
|
||||
timestamp: now - 1000,
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: {} },
|
||||
retryCount: 0,
|
||||
priority: "normal",
|
||||
},
|
||||
]
|
||||
|
||||
mockContextProxy.getGlobalState.mockReturnValue(mockQueue)
|
||||
|
||||
const stats = await manager.getQueueStats()
|
||||
|
||||
expect(stats).toEqual({
|
||||
totalEvents: 3,
|
||||
highPriorityEvents: 1,
|
||||
normalPriorityEvents: 2,
|
||||
retriedEvents: 1,
|
||||
oldestEventAge: expect.any(Number),
|
||||
})
|
||||
expect(stats.oldestEventAge).toBeGreaterThanOrEqual(5000)
|
||||
})
|
||||
|
||||
it("should handle empty queue", async () => {
|
||||
mockContextProxy.getGlobalState.mockReturnValue([])
|
||||
|
||||
const stats = await manager.getQueueStats()
|
||||
|
||||
expect(stats).toEqual({
|
||||
totalEvents: 0,
|
||||
highPriorityEvents: 0,
|
||||
normalPriorityEvents: 0,
|
||||
retriedEvents: 0,
|
||||
oldestEventAge: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("expired events cleanup", () => {
|
||||
it("should remove events older than 7 days during processing", async () => {
|
||||
const now = Date.now()
|
||||
const mockQueue: QueuedTelemetryEvent[] = [
|
||||
{
|
||||
id: "old-event",
|
||||
timestamp: now - 8 * 24 * 60 * 60 * 1000, // 8 days old
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: {} },
|
||||
retryCount: 0,
|
||||
priority: "normal",
|
||||
},
|
||||
{
|
||||
id: "recent-event",
|
||||
timestamp: now - 1 * 24 * 60 * 60 * 1000, // 1 day old
|
||||
event: { event: TelemetryEventName.TASK_CREATED, properties: {} },
|
||||
retryCount: 0,
|
||||
priority: "normal",
|
||||
},
|
||||
]
|
||||
|
||||
mockContextProxy.getGlobalState.mockReturnValue(mockQueue)
|
||||
|
||||
const processCallback = vi.fn().mockResolvedValue(undefined)
|
||||
manager.setProcessCallback(processCallback)
|
||||
|
||||
await manager.processQueue()
|
||||
|
||||
// Should only process the recent event
|
||||
expect(processCallback).toHaveBeenCalledWith([mockQueue[1]])
|
||||
|
||||
// Should update queue to remove old event
|
||||
const queueUpdateCalls = mockContextProxy.updateGlobalState.mock.calls.filter(
|
||||
(call: any[]) => call[0] === "telemetryQueue",
|
||||
)
|
||||
// Find the call that removes the old event
|
||||
const cleanupCall = queueUpdateCalls.find((call: any[]) => !call[1].some((e: any) => e.id === "old-event"))
|
||||
expect(cleanupCall).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
export * from "./CloudService"
|
||||
export * from "./Config"
|
||||
export * from "./TelemetryQueueManager"
|
||||
export * from "./ConnectionMonitor"
|
||||
|
|
|
|||
|
|
@ -146,6 +146,17 @@ export const globalSettingsSchema = z.object({
|
|||
hasOpenedModeSelector: z.boolean().optional(),
|
||||
lastModeExportPath: z.string().optional(),
|
||||
lastModeImportPath: z.string().optional(),
|
||||
|
||||
// Telemetry queue storage
|
||||
telemetryQueue: z.array(z.any()).optional(),
|
||||
telemetryQueueMetadata: z
|
||||
.object({
|
||||
lastProcessedTimestamp: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// Telemetry queue feature flag
|
||||
telemetryQueueEnabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type GlobalSettings = z.infer<typeof globalSettingsSchema>
|
||||
|
|
|
|||
|
|
@ -193,6 +193,24 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
|
|||
|
||||
export type RooCodeTelemetryEvent = z.infer<typeof rooCodeTelemetryEventSchema>
|
||||
|
||||
/**
|
||||
* Telemetry Queue Types
|
||||
*/
|
||||
|
||||
export interface QueuedTelemetryEvent {
|
||||
id: string
|
||||
event: TelemetryEvent
|
||||
timestamp: number
|
||||
retryCount: number
|
||||
lastRetryTimestamp?: number
|
||||
priority: "high" | "normal"
|
||||
}
|
||||
|
||||
export interface TelemetryQueueState {
|
||||
events: QueuedTelemetryEvent[]
|
||||
lastProcessedTimestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* TelemetryEventSubscription
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1521,6 +1521,7 @@ export class ClineProvider
|
|||
historyPreviewCollapsed,
|
||||
cloudUserInfo,
|
||||
cloudIsAuthenticated,
|
||||
cloudIsOnline,
|
||||
sharingEnabled,
|
||||
organizationAllowList,
|
||||
organizationSettingsVersion,
|
||||
|
|
@ -1636,6 +1637,7 @@ export class ClineProvider
|
|||
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
|
||||
cloudUserInfo,
|
||||
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
|
||||
cloudIsOnline: cloudIsOnline ?? true,
|
||||
sharingEnabled: sharingEnabled ?? false,
|
||||
organizationAllowList,
|
||||
organizationSettingsVersion,
|
||||
|
|
@ -1716,6 +1718,16 @@ export class ClineProvider
|
|||
)
|
||||
}
|
||||
|
||||
let cloudIsOnline: boolean = true
|
||||
|
||||
try {
|
||||
cloudIsOnline = CloudService.instance.isOnline()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[getState] failed to get cloud online state: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
let sharingEnabled: boolean = false
|
||||
|
||||
try {
|
||||
|
|
@ -1821,6 +1833,7 @@ export class ClineProvider
|
|||
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
|
||||
cloudUserInfo,
|
||||
cloudIsAuthenticated,
|
||||
cloudIsOnline,
|
||||
sharingEnabled,
|
||||
organizationAllowList,
|
||||
organizationSettingsVersion,
|
||||
|
|
|
|||
|
|
@ -541,6 +541,7 @@ describe("ClineProvider", () => {
|
|||
autoCondenseContext: true,
|
||||
autoCondenseContextPercent: 100,
|
||||
cloudIsAuthenticated: false,
|
||||
cloudIsOnline: true,
|
||||
sharingEnabled: false,
|
||||
profileThresholds: {},
|
||||
hasOpenedModeSelector: false,
|
||||
|
|
@ -1363,6 +1364,7 @@ describe("ClineProvider", () => {
|
|||
enableMcpServerCreation: false,
|
||||
mode: "code" as const,
|
||||
experiments: experimentDefault,
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
|
@ -1381,6 +1383,7 @@ describe("ClineProvider", () => {
|
|||
|
||||
// Test with mcpEnabled: false
|
||||
vi.spyOn(provider, "getState").mockResolvedValueOnce({
|
||||
cloudIsOnline: true,
|
||||
apiConfiguration: {
|
||||
apiProvider: "openrouter" as const,
|
||||
},
|
||||
|
|
@ -1426,6 +1429,7 @@ describe("ClineProvider", () => {
|
|||
},
|
||||
mode: "code" as const,
|
||||
experiments: experimentDefault,
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
// Trigger getSystemPrompt
|
||||
|
|
@ -1460,6 +1464,7 @@ describe("ClineProvider", () => {
|
|||
fuzzyMatchThreshold: 0.8,
|
||||
experiments: experimentDefault,
|
||||
browserToolEnabled: true,
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
// Trigger getSystemPrompt
|
||||
|
|
@ -1494,6 +1499,7 @@ describe("ClineProvider", () => {
|
|||
experiments: experimentDefault,
|
||||
enableMcpServerCreation: true,
|
||||
browserToolEnabled: false,
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
// Trigger getSystemPrompt
|
||||
|
|
@ -1526,6 +1532,7 @@ describe("ClineProvider", () => {
|
|||
mcpEnabled: false,
|
||||
browserViewportSize: "900x600",
|
||||
experiments: experimentDefault,
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
// Trigger getSystemPrompt for architect mode
|
||||
|
|
@ -1555,6 +1562,7 @@ describe("ClineProvider", () => {
|
|||
browserToolEnabled: true,
|
||||
mode: "code", // code mode includes browser tool group
|
||||
experiments: experimentDefault,
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
|
@ -1577,6 +1585,7 @@ describe("ClineProvider", () => {
|
|||
browserToolEnabled: false,
|
||||
mode: "code",
|
||||
experiments: experimentDefault,
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
|
@ -1997,6 +2006,7 @@ describe("ClineProvider", () => {
|
|||
vi.spyOn(provider, "getState").mockResolvedValue({
|
||||
mode: "code",
|
||||
currentApiConfigName: "test-config",
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
// Trigger upsertApiConfiguration
|
||||
|
|
@ -2639,6 +2649,7 @@ describe("ClineProvider - Router Models", () => {
|
|||
litellmApiKey: "litellm-key",
|
||||
litellmBaseUrl: "http://localhost:4000",
|
||||
},
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
const mockModels = {
|
||||
|
|
@ -2700,6 +2711,7 @@ describe("ClineProvider - Router Models", () => {
|
|||
litellmApiKey: "litellm-key",
|
||||
litellmBaseUrl: "http://localhost:4000",
|
||||
},
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
const mockModels = {
|
||||
|
|
@ -2774,6 +2786,7 @@ describe("ClineProvider - Router Models", () => {
|
|||
unboundApiKey: "unbound-key",
|
||||
// No litellm config
|
||||
},
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
const mockModels = {
|
||||
|
|
@ -2810,6 +2823,7 @@ describe("ClineProvider - Router Models", () => {
|
|||
unboundApiKey: "unbound-key",
|
||||
// No litellm config
|
||||
},
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
const mockModels = {
|
||||
|
|
@ -2851,6 +2865,7 @@ describe("ClineProvider - Router Models", () => {
|
|||
lmStudioModelId: "model-1",
|
||||
lmStudioBaseUrl: "http://localhost:1234",
|
||||
},
|
||||
cloudIsOnline: true,
|
||||
} as any)
|
||||
|
||||
const mockModels = {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,30 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
cloudService.on("auth-state-changed", postStateListener)
|
||||
cloudService.on("user-info", postStateListener)
|
||||
cloudService.on("settings-updated", postStateListener)
|
||||
|
||||
// Set initial online status context
|
||||
vscode.commands.executeCommand("setContext", "roo-cline.isOnline", cloudService.isOnline())
|
||||
|
||||
// Store event listeners for proper cleanup
|
||||
const connectionRestoredListener = () => {
|
||||
vscode.commands.executeCommand("setContext", "roo-cline.isOnline", true)
|
||||
}
|
||||
const connectionLostListener = () => {
|
||||
vscode.commands.executeCommand("setContext", "roo-cline.isOnline", false)
|
||||
}
|
||||
|
||||
// Update context when connection status changes
|
||||
cloudService.onConnectionRestored(connectionRestoredListener)
|
||||
cloudService.onConnectionLost(connectionLostListener)
|
||||
|
||||
// Add cleanup to subscriptions
|
||||
context.subscriptions.push({
|
||||
dispose: () => {
|
||||
cloudService.removeConnectionListener("connection-restored", connectionRestoredListener)
|
||||
cloudService.removeConnectionListener("connection-lost", connectionLostListener)
|
||||
},
|
||||
})
|
||||
|
||||
// Add to subscriptions for proper cleanup on deactivate
|
||||
context.subscriptions.push(cloudService)
|
||||
|
||||
|
|
|
|||
|
|
@ -311,6 +311,7 @@ export type ExtensionState = Pick<
|
|||
|
||||
cloudUserInfo: CloudUserInfo | null
|
||||
cloudIsAuthenticated: boolean
|
||||
cloudIsOnline: boolean
|
||||
cloudApiUrl?: string
|
||||
sharingEnabled: boolean
|
||||
organizationAllowList: OrganizationAllowList
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const App = () => {
|
|||
machineId,
|
||||
cloudUserInfo,
|
||||
cloudIsAuthenticated,
|
||||
cloudIsOnline,
|
||||
cloudApiUrl,
|
||||
renderContext,
|
||||
mdmCompliant,
|
||||
|
|
@ -250,6 +251,7 @@ const App = () => {
|
|||
<AccountView
|
||||
userInfo={cloudUserInfo}
|
||||
isAuthenticated={cloudIsAuthenticated}
|
||||
isOnline={cloudIsOnline}
|
||||
cloudApiUrl={cloudApiUrl}
|
||||
onDone={() => switchTab("chat")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import { telemetryClient } from "@src/utils/TelemetryClient"
|
|||
type AccountViewProps = {
|
||||
userInfo: CloudUserInfo | null
|
||||
isAuthenticated: boolean
|
||||
isOnline?: boolean
|
||||
cloudApiUrl?: string
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
export const AccountView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: AccountViewProps) => {
|
||||
export const AccountView = ({ userInfo, isAuthenticated, isOnline = true, cloudApiUrl, onDone }: AccountViewProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
const wasAuthenticatedRef = useRef(false)
|
||||
|
||||
|
|
@ -61,6 +62,12 @@ export const AccountView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }:
|
|||
</div>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{!isOnline && (
|
||||
<div className="flex items-center gap-2 p-3 mb-4 bg-vscode-inputValidation-warningBackground text-vscode-inputValidation-warningForeground rounded">
|
||||
<span className="codicon codicon-warning"></span>
|
||||
<span className="text-sm">{t("account:offlineWarning")}</span>
|
||||
</div>
|
||||
)}
|
||||
{userInfo && (
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="w-16 h-16 mb-3 rounded-full overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
historyPreviewCollapsed: false, // Initialize the new state (default to expanded)
|
||||
cloudUserInfo: null,
|
||||
cloudIsAuthenticated: false,
|
||||
cloudIsOnline: true, // Default cloud online status
|
||||
sharingEnabled: false,
|
||||
organizationAllowList: ORGANIZATION_ALLOW_ALL,
|
||||
organizationSettingsVersion: -1,
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ describe("mergeExtensionState", () => {
|
|||
autoCondenseContext: true,
|
||||
autoCondenseContextPercent: 100,
|
||||
cloudIsAuthenticated: false,
|
||||
cloudIsOnline: true,
|
||||
sharingEnabled: false,
|
||||
profileThresholds: {},
|
||||
hasOpenedModeSelector: false, // Add the new required property
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@
|
|||
"cloudBenefitHistory": "Online task history",
|
||||
"cloudBenefitSharing": "Sharing and collaboration features",
|
||||
"cloudBenefitMetrics": "Task, token, and cost-based usage metrics",
|
||||
"visitCloudWebsite": "Visit Roo Code Cloud"
|
||||
"visitCloudWebsite": "Visit Roo Code Cloud",
|
||||
"offlineWarning": "You are currently offline. Telemetry events will be queued and sent when connection is restored."
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue