From c1d4b9ad55129d7e43a7a874c4bc490d06ad9373 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 15 Aug 2025 11:28:11 -0500 Subject: [PATCH] feat: add telemetry queue persistence system - Implement TelemetryQueueManager for persistent event storage - Add QueuedTelemetryClient base class with retry logic - Update PostHogTelemetryClient to use queuing system - Store queue per-workspace to avoid conflicts - Add exponential backoff retry (1s to 60s max) - Events persist for 24 hours before expiring - Queue limited to 100 events to manage file size - Add comprehensive tests for queue functionality - Disable PostHog's internal queue for better control This ensures telemetry events are not lost during network outages or server downtime, with events persisted to disk and retried automatically when connectivity is restored. --- .../telemetry/src/PostHogTelemetryClient.ts | 78 +++- .../telemetry/src/QueuedTelemetryClient.ts | 161 ++++++++ .../telemetry/src/TelemetryQueueManager.ts | 338 +++++++++++++++++ .../__tests__/QueuedTelemetryClient.test.ts | 230 ++++++++++++ .../__tests__/TelemetryQueueManager.test.ts | 347 ++++++++++++++++++ packages/telemetry/src/index.ts | 2 + src/extension.ts | 4 +- 7 files changed, 1140 insertions(+), 20 deletions(-) create mode 100644 packages/telemetry/src/QueuedTelemetryClient.ts create mode 100644 packages/telemetry/src/TelemetryQueueManager.ts create mode 100644 packages/telemetry/src/__tests__/QueuedTelemetryClient.test.ts create mode 100644 packages/telemetry/src/__tests__/TelemetryQueueManager.test.ts 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) }