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.
This commit is contained in:
daniel-lxs 2025-08-15 11:28:11 -05:00
parent 44086e4a86
commit c1d4b9ad55
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
7 changed files with 1140 additions and 20 deletions

View file

@ -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<void> {
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<void> {
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<void> {
// First shutdown the queue processing
await super.shutdown()
// Then shutdown the PostHog client
await this.client.shutdown()
}
}

View file

@ -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<void> {
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<void>
/**
* Process queued events
*/
private async processQueuedEvents(): Promise<void> {
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<void> {
this.stopRetryTimer()
// Try to send any remaining queued events one last time
if (this.queueManager) {
await this.processQueuedEvents()
}
}
}

View file

@ -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<void>): Promise<void> {
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<void> {
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<void> {
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<string, number>
} {
const now = Date.now()
const oldestEvent = this.queue.length > 0 ? Math.min(...this.queue.map((e) => e.timestamp)) : null
const eventsByClient: Record<string, number> = {}
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<void> {
this.queue = []
await this.persistQueue()
}
/**
* Shutdown the queue manager
*/
public async shutdown(): Promise<void> {
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()
}
}

View file

@ -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<void> {
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)
})
})
})

View file

@ -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()
})
})
})

View file

@ -1,3 +1,5 @@
export * from "./BaseTelemetryClient"
export * from "./PostHogTelemetryClient"
export * from "./TelemetryService"
export * from "./TelemetryQueueManager"
export * from "./QueuedTelemetryClient"

View file

@ -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)
}