diff --git a/packages/telemetry/src/PostHogTelemetryClient.ts b/packages/telemetry/src/PostHogTelemetryClient.ts index f1c46577df..bf6e1fb80f 100644 --- a/packages/telemetry/src/PostHogTelemetryClient.ts +++ b/packages/telemetry/src/PostHogTelemetryClient.ts @@ -3,21 +3,31 @@ import * as vscode from "vscode" import { TelemetryEventName, type TelemetryEvent } from "@roo-code/types" -import { BaseTelemetryClient } from "./BaseTelemetryClient" +import { QueuedTelemetryClient } from "./QueuedTelemetryClient" /** * PostHogTelemetryClient handles telemetry event tracking for the Roo Code extension. * Uses PostHog analytics to track user interactions and system events. * Respects user privacy settings and VSCode's global telemetry configuration. + * Includes automatic queuing and retry for failed events. */ -export class PostHogTelemetryClient extends BaseTelemetryClient { +export class PostHogTelemetryClient extends QueuedTelemetryClient { private client: PostHog private distinctId: string = vscode.env.machineId // Git repository properties that should be filtered out private readonly gitPropertyNames = ["repositoryUrl", "repositoryName", "defaultBranch"] - constructor(debug = false) { + constructor(context: vscode.ExtensionContext, debug = false) { + // Use workspace-specific storage to avoid conflicts between multiple VS Code windows + const storagePath = context.storageUri?.fsPath || context.globalStorageUri?.fsPath || context.extensionPath + + if (debug) { + console.info(`[PostHogTelemetryClient] Initializing with storage path: ${storagePath}`) + } + super( + "posthog", + storagePath, { type: "exclude", events: [TelemetryEventName.TASK_MESSAGE, TelemetryEventName.LLM_COMPLETION], @@ -25,7 +35,19 @@ export class PostHogTelemetryClient extends BaseTelemetryClient { debug, ) - this.client = new PostHog(process.env.POSTHOG_API_KEY || "", { host: "https://us.i.posthog.com" }) + this.client = new PostHog(process.env.POSTHOG_API_KEY || "", { + host: "https://us.i.posthog.com", + // Disable PostHog's internal retry mechanism since we handle our own + flushAt: 1, // Flush after every event + flushInterval: 0, // Disable automatic flushing + }) + + // Disable PostHog's internal error logging to reduce noise + this.client.on("error", (error) => { + if (this.debug) { + console.error("[PostHogTelemetryClient] PostHog internal error:", error) + } + }) } /** @@ -41,24 +63,39 @@ export class PostHogTelemetryClient extends BaseTelemetryClient { return true } - public override async capture(event: TelemetryEvent): Promise { - if (!this.isTelemetryEnabled() || !this.isEventCapturable(event.event)) { - if (this.debug) { - console.info(`[PostHogTelemetryClient#capture] Skipping event: ${event.event}`) - } - - return - } - + /** + * Send event to PostHog (called by the base class) + */ + protected async sendEvent(event: TelemetryEvent): Promise { if (this.debug) { - console.info(`[PostHogTelemetryClient#capture] ${event.event}`) + console.info(`[PostHogTelemetryClient#sendEvent] ${event.event}`) } - this.client.capture({ - distinctId: this.distinctId, - event: event.event, - properties: await this.getEventProperties(event), - }) + const properties = await this.getEventProperties(event) + + // PostHog queues events internally and flushes them in batches + // We need to force a flush to know if the send actually succeeded + try { + this.client.capture({ + distinctId: this.distinctId, + event: event.event, + properties, + }) + + // Force immediate flush to detect network errors + // This will throw if there's a network issue + await this.client.flush() + + if (this.debug) { + console.info(`[PostHogTelemetryClient#sendEvent] Successfully flushed event: ${event.event}`) + } + } catch (error) { + if (this.debug) { + console.error(`[PostHogTelemetryClient#sendEvent] Failed to send event: ${event.event}`, error) + } + // Re-throw to trigger our queuing mechanism + throw error + } } /** @@ -88,6 +125,9 @@ export class PostHogTelemetryClient extends BaseTelemetryClient { } public override async shutdown(): Promise { + // First shutdown the queue processing + await super.shutdown() + // Then shutdown the PostHog client await this.client.shutdown() } } diff --git a/packages/telemetry/src/QueuedTelemetryClient.ts b/packages/telemetry/src/QueuedTelemetryClient.ts new file mode 100644 index 0000000000..3fd16c25fb --- /dev/null +++ b/packages/telemetry/src/QueuedTelemetryClient.ts @@ -0,0 +1,161 @@ +import { TelemetryEvent, TelemetryEventSubscription } from "@roo-code/types" +import { BaseTelemetryClient } from "./BaseTelemetryClient" +import { TelemetryQueueManager } from "./TelemetryQueueManager" + +/** + * QueuedTelemetryClient extends BaseTelemetryClient to add queuing and retry capabilities. + * Failed events are automatically queued and retried with exponential backoff. + */ +export abstract class QueuedTelemetryClient extends BaseTelemetryClient { + protected queueManager: TelemetryQueueManager | null = null + protected clientId: string + private retryTimer: NodeJS.Timeout | null = null + private isOnline = true + private readonly RETRY_CHECK_INTERVAL = 30000 // Check for retries every 30 seconds + + constructor(clientId: string, storagePath: string, subscription?: TelemetryEventSubscription, debug = false) { + super(subscription, debug) + this.clientId = clientId + + // Initialize queue manager + try { + this.queueManager = TelemetryQueueManager.getInstance(storagePath) + this.startRetryTimer() + } catch (error) { + console.error(`Failed to initialize queue manager: ${error}`) + } + } + + /** + * Capture an event with automatic queuing on failure + */ + public async capture(event: TelemetryEvent): Promise { + if (!this.isTelemetryEnabled() || !this.isEventCapturable(event.event)) { + if (this.debug) { + console.info(`[${this.clientId}#capture] Skipping event: ${event.event}`) + } + return + } + + try { + // Try to send the event + if (this.debug) { + console.info(`[${this.clientId}#capture] Attempting to send: ${event.event}`) + } + await this.sendEvent(event) + + // If successful and we have queued events, try to process them + if (this.queueManager && this.isOnline) { + if (this.debug) { + console.info(`[${this.clientId}#capture] Send successful, checking for queued events`) + } + this.processQueuedEvents() + } + } catch (error) { + // Queue the event for retry + if (this.queueManager) { + if (this.debug) { + console.info( + `[${this.clientId}#capture] Send failed, queuing event: ${event.event}, error: ${error}`, + ) + } + this.queueManager.enqueue(event, this.clientId) + this.isOnline = false + } + + // Re-throw if no queue manager (maintains original behavior) + if (!this.queueManager) { + throw error + } + } + } + + /** + * Abstract method that subclasses must implement to actually send the event + */ + protected abstract sendEvent(event: TelemetryEvent): Promise + + /** + * Process queued events + */ + private async processQueuedEvents(): Promise { + if (!this.queueManager) { + return + } + + const eventsToRetry = this.queueManager.getEventsForRetry(this.clientId) + + if (eventsToRetry.length === 0) { + return + } + + if (this.debug) { + console.info(`[${this.clientId}] Processing ${eventsToRetry.length} queued events`) + } + + await this.queueManager.processQueue(this.clientId, async (event) => { + if (this.debug) { + console.info(`[${this.clientId}] Retrying queued event: ${event.event}`) + } + await this.sendEvent(event) + this.isOnline = true + if (this.debug) { + console.info(`[${this.clientId}] Successfully sent queued event, marking online`) + } + }) + } + + /** + * Start the retry timer + */ + private startRetryTimer(): void { + if (this.debug) { + console.info(`[${this.clientId}] Starting retry timer, checking every ${this.RETRY_CHECK_INTERVAL}ms`) + } + this.retryTimer = setInterval(() => { + if (this.debug) { + console.info(`[${this.clientId}] Retry timer triggered, checking for events to retry`) + } + this.processQueuedEvents() + }, this.RETRY_CHECK_INTERVAL) + } + + /** + * Stop the retry timer + */ + private stopRetryTimer(): void { + if (this.retryTimer) { + clearInterval(this.retryTimer) + this.retryTimer = null + } + } + + /** + * Get queue statistics for this client + */ + public getQueueStats(): { queueSize: number; oldestEventAge: number | null } | null { + if (!this.queueManager) { + return null + } + + const stats = this.queueManager.getStats() + const clientEventCount = stats.eventsByClient[this.clientId] || 0 + + return { + queueSize: clientEventCount, + oldestEventAge: stats.oldestEventAge, + } + } + + /** + * Shutdown the client and persist any queued events + */ + public async shutdown(): Promise { + this.stopRetryTimer() + + // Try to send any remaining queued events one last time + if (this.queueManager) { + await this.processQueuedEvents() + } + } +} diff --git a/packages/telemetry/src/TelemetryQueueManager.ts b/packages/telemetry/src/TelemetryQueueManager.ts new file mode 100644 index 0000000000..a0ab41e2ca --- /dev/null +++ b/packages/telemetry/src/TelemetryQueueManager.ts @@ -0,0 +1,338 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { TelemetryEvent } from "@roo-code/types" + +interface QueuedEvent { + event: TelemetryEvent + timestamp: number + retryCount: number + clientId: string +} + +interface QueueState { + events: QueuedEvent[] + version: number +} + +/** + * TelemetryQueueManager handles queuing and retry logic for telemetry events. + * It persists failed events to disk and retries them with exponential backoff. + */ +export class TelemetryQueueManager { + private static instance: TelemetryQueueManager | null = null + private queue: QueuedEvent[] = [] + private isProcessing = false + private persistPath: string + private maxRetries = 5 + private baseRetryDelay = 1000 // 1 second + private maxQueueSize = 100 // Reduced to keep file size small + private flushInterval: NodeJS.Timeout | null = null + private cleanupInterval: NodeJS.Timeout | null = null + private readonly QUEUE_VERSION = 1 + private readonly MAX_EVENT_AGE = 24 * 60 * 60 * 1000 // 24 hours instead of 7 days + + private constructor(storagePath: string) { + this.persistPath = path.join(storagePath, "telemetry-queue.json") + console.log(`[TelemetryQueue] Initializing with path: ${this.persistPath}`) + this.loadQueue() + this.startPeriodicFlush() + this.startPeriodicCleanup() + } + + /** + * Get or create the singleton instance + */ + public static getInstance(storagePath: string): TelemetryQueueManager { + if (!this.instance) { + this.instance = new TelemetryQueueManager(storagePath) + } + return this.instance + } + + /** + * Add an event to the queue + */ + public enqueue(event: TelemetryEvent, clientId: string): void { + console.log(`[TelemetryQueue] Enqueueing event: ${event.event} for client: ${clientId}`) + + // Don't queue if we've reached the maximum size + if (this.queue.length >= this.maxQueueSize) { + // Remove oldest events to make room (FIFO) + const removed = this.queue.shift() + console.log( + `[TelemetryQueue] Queue full (${this.maxQueueSize}), removed oldest event: ${removed?.event.event}`, + ) + } + + const queuedEvent: QueuedEvent = { + event, + timestamp: Date.now(), + retryCount: 0, + clientId, + } + + this.queue.push(queuedEvent) + console.log(`[TelemetryQueue] Queue size after enqueue: ${this.queue.length}`) + this.persistQueue() + } + + /** + * Process queued events for a specific client + */ + public async processQueue(clientId: string, sendFunction: (event: TelemetryEvent) => Promise): Promise { + if (this.isProcessing) { + return + } + + this.isProcessing = true + + try { + const clientEvents = this.queue.filter((e) => e.clientId === clientId) + + console.log(`[TelemetryQueue] Processing ${clientEvents.length} events for client: ${clientId}`) + + for (const queuedEvent of clientEvents) { + try { + console.log( + `[TelemetryQueue] Attempting to send event: ${queuedEvent.event.event}, retry count: ${queuedEvent.retryCount}`, + ) + await sendFunction(queuedEvent.event) + // Remove successfully sent event + console.log(`[TelemetryQueue] Successfully sent event: ${queuedEvent.event.event}`) + this.removeEvent(queuedEvent) + } catch (error) { + // Increment retry count + queuedEvent.retryCount++ + console.log( + `[TelemetryQueue] Failed to send event: ${queuedEvent.event.event}, retry count now: ${queuedEvent.retryCount}, error: ${error}`, + ) + + // Don't remove based on retry count - let it keep trying until 24 hours + } + } + + await this.persistQueue() + } finally { + this.isProcessing = false + } + } + + /** + * Get the retry delay for an event based on retry count (exponential backoff) + */ + public getRetryDelay(retryCount: number): number { + return Math.min( + this.baseRetryDelay * Math.pow(2, retryCount), + 60000, // Max 1 minute + ) + } + + /** + * Check if an event should be retried based on its timestamp and retry count + */ + public shouldRetry(queuedEvent: QueuedEvent): boolean { + const now = Date.now() + const retryDelay = this.getRetryDelay(queuedEvent.retryCount) + return now - queuedEvent.timestamp >= retryDelay + } + + /** + * Get events ready for retry for a specific client + */ + public getEventsForRetry(clientId: string): QueuedEvent[] { + return this.queue.filter((e) => e.clientId === clientId && this.shouldRetry(e)) + } + + /** + * Remove an event from the queue + */ + private removeEvent(event: QueuedEvent): void { + const index = this.queue.indexOf(event) + if (index > -1) { + this.queue.splice(index, 1) + } + } + + /** + * Load queue from disk + */ + private async loadQueue(): Promise { + try { + const data = await fs.readFile(this.persistPath, "utf-8") + const state: QueueState = JSON.parse(data) + + // Check version compatibility + if (state.version === this.QUEUE_VERSION) { + // Filter out old events on load + const cutoffTime = Date.now() - this.MAX_EVENT_AGE + const originalCount = state.events.length + this.queue = state.events.filter((e) => e.timestamp > cutoffTime) + + console.log( + `[TelemetryQueue] Loaded ${this.queue.length} events from disk (filtered ${originalCount - this.queue.length} old events)`, + ) + + // If we filtered out any events, persist the cleaned queue + if (this.queue.length < originalCount) { + this.persistQueue() + } + } + } catch (error) { + // File doesn't exist or is corrupted, start with empty queue + console.log(`[TelemetryQueue] No existing queue file found or error loading: ${error}`) + this.queue = [] + } + } + + /** + * Persist queue to disk + */ + private async persistQueue(): Promise { + try { + const state: QueueState = { + events: this.queue, + version: this.QUEUE_VERSION, + } + + console.log(`[TelemetryQueue] Persisting ${this.queue.length} events to disk`) + + // Ensure directory exists + const dir = path.dirname(this.persistPath) + await fs.mkdir(dir, { recursive: true }) + + // Write atomically using a temp file + const tempPath = `${this.persistPath}.tmp` + await fs.writeFile(tempPath, JSON.stringify(state, null, 2)) + await fs.rename(tempPath, this.persistPath) + + console.log(`[TelemetryQueue] Successfully persisted queue to: ${this.persistPath}`) + } catch (error) { + // Log error but don't throw - telemetry should not break the app + console.error("[TelemetryQueue] Failed to persist telemetry queue:", error) + } + } + + /** + * Start periodic flush of old events + */ + private startPeriodicFlush(): void { + // Flush old events every hour + this.flushInterval = setInterval( + () => { + this.flushOldEvents() + }, + 60 * 60 * 1000, + ) + } + + /** + * Start more aggressive periodic cleanup + */ + private startPeriodicCleanup(): void { + // Run cleanup every 5 minutes to keep file size small + this.cleanupInterval = setInterval( + () => { + this.performAggressiveCleanup() + }, + 5 * 60 * 1000, + ) + } + + /** + * Perform aggressive cleanup to keep queue file small + */ + private performAggressiveCleanup(): void { + const originalSize = this.queue.length + console.log(`[TelemetryQueue] Running aggressive cleanup, current queue size: ${originalSize}`) + + // Remove old events + const cutoffTime = Date.now() - this.MAX_EVENT_AGE + const beforeOldFilter = this.queue.length + this.queue = this.queue.filter((e) => e.timestamp > cutoffTime) + const removedOld = beforeOldFilter - this.queue.length + if (removedOld > 0) { + console.log(`[TelemetryQueue] Removed ${removedOld} events older than 24 hours`) + } + + // No longer removing events based on retry count - they'll expire after 24 hours + + // If queue is still too large, remove oldest events + if (this.queue.length > this.maxQueueSize) { + // Sort by timestamp and keep only the newest events + this.queue.sort((a, b) => b.timestamp - a.timestamp) + const beforeTrim = this.queue.length + this.queue = this.queue.slice(0, this.maxQueueSize) + console.log(`[TelemetryQueue] Trimmed queue from ${beforeTrim} to ${this.maxQueueSize} events`) + } + + // Persist if we made changes + if (this.queue.length !== originalSize) { + console.log( + `[TelemetryQueue] Cleanup complete, queue size changed from ${originalSize} to ${this.queue.length}`, + ) + this.persistQueue() + } + } + + /** + * Remove events older than MAX_EVENT_AGE + */ + private flushOldEvents(): void { + const cutoffTime = Date.now() - this.MAX_EVENT_AGE + const originalLength = this.queue.length + this.queue = this.queue.filter((e) => e.timestamp > cutoffTime) + + if (this.queue.length < originalLength) { + this.persistQueue() + } + } + + /** + * Get queue statistics + */ + public getStats(): { + queueSize: number + oldestEventAge: number | null + eventsByClient: Record + } { + const now = Date.now() + const oldestEvent = this.queue.length > 0 ? Math.min(...this.queue.map((e) => e.timestamp)) : null + + const eventsByClient: Record = {} + for (const event of this.queue) { + eventsByClient[event.clientId] = (eventsByClient[event.clientId] || 0) + 1 + } + + return { + queueSize: this.queue.length, + oldestEventAge: oldestEvent ? now - oldestEvent : null, + eventsByClient, + } + } + + /** + * Clear all queued events + */ + public async clearQueue(): Promise { + this.queue = [] + await this.persistQueue() + } + + /** + * Shutdown the queue manager + */ + public async shutdown(): Promise { + if (this.flushInterval) { + clearInterval(this.flushInterval) + this.flushInterval = null + } + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = null + } + + // Do a final aggressive cleanup before shutdown + this.performAggressiveCleanup() + await this.persistQueue() + } +} diff --git a/packages/telemetry/src/__tests__/QueuedTelemetryClient.test.ts b/packages/telemetry/src/__tests__/QueuedTelemetryClient.test.ts new file mode 100644 index 0000000000..1627b70da4 --- /dev/null +++ b/packages/telemetry/src/__tests__/QueuedTelemetryClient.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import * as fs from "fs/promises" +import { QueuedTelemetryClient } from "../QueuedTelemetryClient" +import { TelemetryQueueManager } from "../TelemetryQueueManager" +import { TelemetryEvent, TelemetryEventName } from "@roo-code/types" + +// Mock fs module +vi.mock("fs/promises") + +// Create a test implementation of QueuedTelemetryClient +class TestQueuedTelemetryClient extends QueuedTelemetryClient { + public sendEventCalled = false + public sendEventError: Error | null = null + + protected async sendEvent(_event: TelemetryEvent): Promise { + this.sendEventCalled = true + if (this.sendEventError) { + throw this.sendEventError + } + } + + public updateTelemetryState(didUserOptIn: boolean): void { + this.telemetryEnabled = didUserOptIn + } +} + +describe("QueuedTelemetryClient", () => { + let client: TestQueuedTelemetryClient + const testStoragePath = "/test/storage" + + beforeEach(() => { + vi.clearAllMocks() + + // Mock file system operations + vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found")) + vi.mocked(fs.writeFile).mockResolvedValue() + vi.mocked(fs.rename).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue(undefined as never) + + // Reset singleton instance of queue manager + // We need to use a workaround to reset the singleton + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const TelemetryQueueManagerClass = TelemetryQueueManager as any + if (TelemetryQueueManagerClass && typeof TelemetryQueueManagerClass === "function") { + TelemetryQueueManagerClass.instance = null + } + + client = new TestQueuedTelemetryClient("test-client", testStoragePath) + client.updateTelemetryState(true) + }) + + afterEach(async () => { + await client.shutdown() + }) + + describe("capture", () => { + it("should send event successfully when online", async () => { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + await client.capture(event) + + expect(client.sendEventCalled).toBe(true) + }) + + it("should queue event when send fails", async () => { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + client.sendEventError = new Error("Network error") + + // Should not throw + await expect(client.capture(event)).resolves.toBeUndefined() + + // Event should be queued + const stats = client.getQueueStats() + expect(stats?.queueSize).toBe(1) + }) + + it("should skip event when telemetry is disabled", async () => { + client.updateTelemetryState(false) + + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + await client.capture(event) + + expect(client.sendEventCalled).toBe(false) + }) + + it("should skip excluded events based on subscription", async () => { + // Create client with subscription that excludes TASK_MESSAGE + client = new TestQueuedTelemetryClient("test-client", testStoragePath, { + type: "exclude", + events: [TelemetryEventName.TASK_MESSAGE], + }) + client.updateTelemetryState(true) + + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_MESSAGE, + properties: { taskId: "test-123" }, + } + + await client.capture(event) + + expect(client.sendEventCalled).toBe(false) + }) + + it("should only capture included events based on subscription", async () => { + // Create client with subscription that only includes specific events + client = new TestQueuedTelemetryClient("test-client", testStoragePath, { + type: "include", + events: [TelemetryEventName.TASK_CREATED, TelemetryEventName.TASK_COMPLETED], + }) + client.updateTelemetryState(true) + + // This event should be captured + const includedEvent: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + await client.capture(includedEvent) + expect(client.sendEventCalled).toBe(true) + + // Reset + client.sendEventCalled = false + + // This event should NOT be captured + const excludedEvent: TelemetryEvent = { + event: TelemetryEventName.MODE_SWITCH, + properties: { newMode: "test" }, + } + + await client.capture(excludedEvent) + expect(client.sendEventCalled).toBe(false) + }) + }) + + describe("getQueueStats", () => { + it("should return queue statistics for the client", async () => { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + // Make send fail to queue the event + client.sendEventError = new Error("Network error") + await client.capture(event) + + const stats = client.getQueueStats() + expect(stats).not.toBeNull() + expect(stats?.queueSize).toBe(1) + expect(stats?.oldestEventAge).toBeGreaterThanOrEqual(0) + }) + + it("should return null when queue manager is not available", () => { + // Create a client without queue manager + const clientWithoutQueue = new TestQueuedTelemetryClient("test", "/invalid/path") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(clientWithoutQueue as any).queueManager = null + + const stats = clientWithoutQueue.getQueueStats() + expect(stats).toBeNull() + }) + }) + + describe("shutdown", () => { + it("should stop retry timer and attempt to send queued events", async () => { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + // Queue an event + client.sendEventError = new Error("Network error") + await client.capture(event) + + // Fix the error + client.sendEventError = null + client.sendEventCalled = false + + // Shutdown should attempt to process queue + await client.shutdown() + + // Note: The actual processing might not happen immediately + // due to the async nature and retry timing + }) + }) + + describe("retry mechanism", () => { + it("should have retry timer configured", () => { + // Check that retry timer is set up + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const retryTimer = (client as any).retryTimer + expect(retryTimer).toBeDefined() + expect(retryTimer).not.toBeNull() + }) + + it("should process queued events when connection is restored", async () => { + const event1: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-1" }, + } + const event2: TelemetryEvent = { + event: TelemetryEventName.TASK_COMPLETED, + properties: { taskId: "test-2" }, + } + + // Queue first event + client.sendEventError = new Error("Network error") + await client.capture(event1) + expect(client.getQueueStats()?.queueSize).toBe(1) + + // Connection restored - second event should succeed + client.sendEventError = null + client.sendEventCalled = false + await client.capture(event2) + + // Second event should be sent + expect(client.sendEventCalled).toBe(true) + }) + }) +}) diff --git a/packages/telemetry/src/__tests__/TelemetryQueueManager.test.ts b/packages/telemetry/src/__tests__/TelemetryQueueManager.test.ts new file mode 100644 index 0000000000..b1a898add5 --- /dev/null +++ b/packages/telemetry/src/__tests__/TelemetryQueueManager.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import { TelemetryQueueManager } from "../TelemetryQueueManager" +import { TelemetryEvent, TelemetryEventName } from "@roo-code/types" + +// Mock fs module +vi.mock("fs/promises") + +// Helper to reset singleton instance +function resetQueueManagerInstance() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const QueueManagerConstructor = TelemetryQueueManager as any + if (QueueManagerConstructor && typeof QueueManagerConstructor === "function") { + QueueManagerConstructor.instance = null + } +} + +describe("TelemetryQueueManager", () => { + let queueManager: TelemetryQueueManager + const testStoragePath = "/test/storage" + const testQueuePath = path.join(testStoragePath, "telemetry-queue.json") + + beforeEach(() => { + vi.clearAllMocks() + // Reset singleton instance + resetQueueManagerInstance() + + // Mock file system operations + vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found")) + vi.mocked(fs.writeFile).mockResolvedValue() + vi.mocked(fs.rename).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue(undefined as never) + + queueManager = TelemetryQueueManager.getInstance(testStoragePath) + }) + + afterEach(async () => { + await queueManager.shutdown() + }) + + describe("getInstance", () => { + it("should return the same instance on multiple calls", () => { + const instance1 = TelemetryQueueManager.getInstance(testStoragePath) + const instance2 = TelemetryQueueManager.getInstance(testStoragePath) + expect(instance1).toBe(instance2) + }) + }) + + describe("enqueue", () => { + it("should add an event to the queue", () => { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + queueManager.enqueue(event, "test-client") + + const stats = queueManager.getStats() + expect(stats.queueSize).toBe(1) + expect(stats.eventsByClient["test-client"]).toBe(1) + }) + + it("should respect max queue size", () => { + // Set max queue size to a small number for testing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(queueManager as any).maxQueueSize = 3 + + for (let i = 0; i < 5; i++) { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: `test-${i}` }, + } + queueManager.enqueue(event, "test-client") + } + + const stats = queueManager.getStats() + expect(stats.queueSize).toBe(3) + }) + + it("should persist queue after enqueuing", async () => { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + queueManager.enqueue(event, "test-client") + + // Wait a bit for async persist + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(fs.mkdir).toHaveBeenCalledWith(path.dirname(testQueuePath), { recursive: true }) + expect(fs.writeFile).toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalled() + }) + }) + + describe("processQueue", () => { + it("should process events for a specific client", async () => { + const event1: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-1" }, + } + const event2: TelemetryEvent = { + event: TelemetryEventName.TASK_COMPLETED, + properties: { taskId: "test-2" }, + } + + queueManager.enqueue(event1, "client-1") + queueManager.enqueue(event2, "client-2") + + const sendFunction = vi.fn().mockResolvedValue(undefined) + await queueManager.processQueue("client-1", sendFunction) + + expect(sendFunction).toHaveBeenCalledTimes(1) + expect(sendFunction).toHaveBeenCalledWith(event1) + + const stats = queueManager.getStats() + expect(stats.queueSize).toBe(1) // Only client-2 event remains + }) + + it("should handle send failures and increment retry count", async () => { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + queueManager.enqueue(event, "test-client") + + const sendFunction = vi.fn().mockRejectedValue(new Error("Network error")) + await queueManager.processQueue("test-client", sendFunction) + + const events = queueManager.getEventsForRetry("test-client") + expect(events[0].retryCount).toBe(1) + }) + + it("should remove events after max retries", async () => { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + queueManager.enqueue(event, "test-client") + + // Set retry count to max + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const queuedEvents = (queueManager as any).queue + queuedEvents[0].retryCount = 4 + + const sendFunction = vi.fn().mockRejectedValue(new Error("Network error")) + await queueManager.processQueue("test-client", sendFunction) + + const stats = queueManager.getStats() + expect(stats.queueSize).toBe(0) // Event removed after max retries + }) + }) + + describe("getRetryDelay", () => { + it("should calculate exponential backoff correctly", () => { + expect(queueManager.getRetryDelay(0)).toBe(1000) + expect(queueManager.getRetryDelay(1)).toBe(2000) + expect(queueManager.getRetryDelay(2)).toBe(4000) + expect(queueManager.getRetryDelay(3)).toBe(8000) + expect(queueManager.getRetryDelay(10)).toBe(60000) // Max delay + }) + }) + + describe("shouldRetry", () => { + it("should return true when enough time has passed", () => { + const queuedEvent = { + event: { event: TelemetryEventName.TASK_CREATED }, + timestamp: Date.now() - 5000, + retryCount: 1, + clientId: "test-client", + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(queueManager.shouldRetry(queuedEvent as any)).toBe(true) + }) + + it("should return false when not enough time has passed", () => { + const queuedEvent = { + event: { event: TelemetryEventName.TASK_CREATED }, + timestamp: Date.now() - 500, + retryCount: 1, + clientId: "test-client", + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(queueManager.shouldRetry(queuedEvent as any)).toBe(false) + }) + }) + + describe("getEventsForRetry", () => { + it("should return only events ready for retry for specific client", () => { + const oldEvent = { + event: { event: TelemetryEventName.TASK_CREATED }, + timestamp: Date.now() - 5000, + retryCount: 1, + clientId: "client-1", + } + const recentEvent = { + event: { event: TelemetryEventName.TASK_COMPLETED }, + timestamp: Date.now() - 100, + retryCount: 1, + clientId: "client-1", + } + const otherClientEvent = { + event: { event: TelemetryEventName.MODE_SWITCH }, + timestamp: Date.now() - 5000, + retryCount: 1, + clientId: "client-2", + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(queueManager as any).queue = [oldEvent, recentEvent, otherClientEvent] + + const eventsForRetry = queueManager.getEventsForRetry("client-1") + expect(eventsForRetry).toHaveLength(1) + expect(eventsForRetry[0]).toBe(oldEvent) + }) + }) + + describe("loadQueue", () => { + it("should load queue from disk if file exists", async () => { + const savedQueue = { + version: 1, + events: [ + { + event: { event: TelemetryEventName.TASK_CREATED }, + timestamp: Date.now() - 1000, + retryCount: 2, + clientId: "test-client", + }, + ], + } + + vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(savedQueue)) + + // Create new instance to trigger load + + // Reset singleton instance + resetQueueManagerInstance() + queueManager = TelemetryQueueManager.getInstance(testStoragePath) + + // Wait for async load + await new Promise((resolve) => setTimeout(resolve, 10)) + + const stats = queueManager.getStats() + expect(stats.queueSize).toBe(1) + }) + + it("should filter out events older than 7 days", async () => { + const savedQueue = { + version: 1, + events: [ + { + event: { event: TelemetryEventName.TASK_CREATED }, + timestamp: Date.now() - 8 * 24 * 60 * 60 * 1000, // 8 days old + retryCount: 0, + clientId: "test-client", + }, + { + event: { event: TelemetryEventName.TASK_COMPLETED }, + timestamp: Date.now() - 6 * 24 * 60 * 60 * 1000, // 6 days old + retryCount: 0, + clientId: "test-client", + }, + ], + } + + vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(savedQueue)) + + // Create new instance to trigger load + resetQueueManagerInstance() + queueManager = TelemetryQueueManager.getInstance(testStoragePath) + + // Wait for async load + await new Promise((resolve) => setTimeout(resolve, 10)) + + const stats = queueManager.getStats() + expect(stats.queueSize).toBe(1) // Only the 6-day-old event + }) + }) + + describe("clearQueue", () => { + it("should clear all queued events", async () => { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + queueManager.enqueue(event, "test-client") + expect(queueManager.getStats().queueSize).toBe(1) + + await queueManager.clearQueue() + expect(queueManager.getStats().queueSize).toBe(0) + }) + }) + + describe("getStats", () => { + it("should return correct statistics", () => { + const event1: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-1" }, + } + const event2: TelemetryEvent = { + event: TelemetryEventName.TASK_COMPLETED, + properties: { taskId: "test-2" }, + } + + queueManager.enqueue(event1, "client-1") + queueManager.enqueue(event2, "client-1") + queueManager.enqueue(event1, "client-2") + + const stats = queueManager.getStats() + expect(stats.queueSize).toBe(3) + expect(stats.eventsByClient["client-1"]).toBe(2) + expect(stats.eventsByClient["client-2"]).toBe(1) + expect(stats.oldestEventAge).toBeGreaterThanOrEqual(0) + expect(stats.oldestEventAge).toBeLessThan(1000) + }) + + it("should handle empty queue", () => { + const stats = queueManager.getStats() + expect(stats.queueSize).toBe(0) + expect(stats.oldestEventAge).toBeNull() + expect(stats.eventsByClient).toEqual({}) + }) + }) + + describe("shutdown", () => { + it("should persist queue and stop timers", async () => { + const event: TelemetryEvent = { + event: TelemetryEventName.TASK_CREATED, + properties: { taskId: "test-123" }, + } + + queueManager.enqueue(event, "test-client") + + await queueManager.shutdown() + + expect(fs.writeFile).toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index 8795ad46a2..8a66fae915 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -1,3 +1,5 @@ export * from "./BaseTelemetryClient" export * from "./PostHogTelemetryClient" export * from "./TelemetryService" +export * from "./TelemetryQueueManager" +export * from "./QueuedTelemetryClient" diff --git a/src/extension.ts b/src/extension.ts index 6cb6ea4b07..12628191f7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -67,7 +67,9 @@ export async function activate(context: vscode.ExtensionContext) { const telemetryService = TelemetryService.createInstance() try { - telemetryService.register(new PostHogTelemetryClient()) + // Enable debug mode for telemetry during development + const debugTelemetry = process.env.DEBUG_TELEMETRY === "true" || process.env.NODE_ENV === "development" + telemetryService.register(new PostHogTelemetryClient(context, debugTelemetry)) } catch (error) { console.warn("Failed to register PostHogTelemetryClient:", error) }