Skip to content

Commit c1d4b9a

Browse files
committed
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.
1 parent 44086e4 commit c1d4b9a

File tree

7 files changed

+1140
-20
lines changed

7 files changed

+1140
-20
lines changed

packages/telemetry/src/PostHogTelemetryClient.ts

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,51 @@ import * as vscode from "vscode"
33

44
import { TelemetryEventName, type TelemetryEvent } from "@roo-code/types"
55

6-
import { BaseTelemetryClient } from "./BaseTelemetryClient"
6+
import { QueuedTelemetryClient } from "./QueuedTelemetryClient"
77

88
/**
99
* PostHogTelemetryClient handles telemetry event tracking for the Roo Code extension.
1010
* Uses PostHog analytics to track user interactions and system events.
1111
* Respects user privacy settings and VSCode's global telemetry configuration.
12+
* Includes automatic queuing and retry for failed events.
1213
*/
13-
export class PostHogTelemetryClient extends BaseTelemetryClient {
14+
export class PostHogTelemetryClient extends QueuedTelemetryClient {
1415
private client: PostHog
1516
private distinctId: string = vscode.env.machineId
1617
// Git repository properties that should be filtered out
1718
private readonly gitPropertyNames = ["repositoryUrl", "repositoryName", "defaultBranch"]
1819

19-
constructor(debug = false) {
20+
constructor(context: vscode.ExtensionContext, debug = false) {
21+
// Use workspace-specific storage to avoid conflicts between multiple VS Code windows
22+
const storagePath = context.storageUri?.fsPath || context.globalStorageUri?.fsPath || context.extensionPath
23+
24+
if (debug) {
25+
console.info(`[PostHogTelemetryClient] Initializing with storage path: ${storagePath}`)
26+
}
27+
2028
super(
29+
"posthog",
30+
storagePath,
2131
{
2232
type: "exclude",
2333
events: [TelemetryEventName.TASK_MESSAGE, TelemetryEventName.LLM_COMPLETION],
2434
},
2535
debug,
2636
)
2737

28-
this.client = new PostHog(process.env.POSTHOG_API_KEY || "", { host: "https://us.i.posthog.com" })
38+
this.client = new PostHog(process.env.POSTHOG_API_KEY || "", {
39+
host: "https://us.i.posthog.com",
40+
// Disable PostHog's internal retry mechanism since we handle our own
41+
flushAt: 1, // Flush after every event
42+
flushInterval: 0, // Disable automatic flushing
43+
})
44+
45+
// Disable PostHog's internal error logging to reduce noise
46+
this.client.on("error", (error) => {
47+
if (this.debug) {
48+
console.error("[PostHogTelemetryClient] PostHog internal error:", error)
49+
}
50+
})
2951
}
3052

3153
/**
@@ -41,24 +63,39 @@ export class PostHogTelemetryClient extends BaseTelemetryClient {
4163
return true
4264
}
4365

44-
public override async capture(event: TelemetryEvent): Promise<void> {
45-
if (!this.isTelemetryEnabled() || !this.isEventCapturable(event.event)) {
46-
if (this.debug) {
47-
console.info(`[PostHogTelemetryClient#capture] Skipping event: ${event.event}`)
48-
}
49-
50-
return
51-
}
52-
66+
/**
67+
* Send event to PostHog (called by the base class)
68+
*/
69+
protected async sendEvent(event: TelemetryEvent): Promise<void> {
5370
if (this.debug) {
54-
console.info(`[PostHogTelemetryClient#capture] ${event.event}`)
71+
console.info(`[PostHogTelemetryClient#sendEvent] ${event.event}`)
5572
}
5673

57-
this.client.capture({
58-
distinctId: this.distinctId,
59-
event: event.event,
60-
properties: await this.getEventProperties(event),
61-
})
74+
const properties = await this.getEventProperties(event)
75+
76+
// PostHog queues events internally and flushes them in batches
77+
// We need to force a flush to know if the send actually succeeded
78+
try {
79+
this.client.capture({
80+
distinctId: this.distinctId,
81+
event: event.event,
82+
properties,
83+
})
84+
85+
// Force immediate flush to detect network errors
86+
// This will throw if there's a network issue
87+
await this.client.flush()
88+
89+
if (this.debug) {
90+
console.info(`[PostHogTelemetryClient#sendEvent] Successfully flushed event: ${event.event}`)
91+
}
92+
} catch (error) {
93+
if (this.debug) {
94+
console.error(`[PostHogTelemetryClient#sendEvent] Failed to send event: ${event.event}`, error)
95+
}
96+
// Re-throw to trigger our queuing mechanism
97+
throw error
98+
}
6299
}
63100

64101
/**
@@ -88,6 +125,9 @@ export class PostHogTelemetryClient extends BaseTelemetryClient {
88125
}
89126

90127
public override async shutdown(): Promise<void> {
128+
// First shutdown the queue processing
129+
await super.shutdown()
130+
// Then shutdown the PostHog client
91131
await this.client.shutdown()
92132
}
93133
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { TelemetryEvent, TelemetryEventSubscription } from "@roo-code/types"
2+
import { BaseTelemetryClient } from "./BaseTelemetryClient"
3+
import { TelemetryQueueManager } from "./TelemetryQueueManager"
4+
5+
/**
6+
* QueuedTelemetryClient extends BaseTelemetryClient to add queuing and retry capabilities.
7+
* Failed events are automatically queued and retried with exponential backoff.
8+
*/
9+
export abstract class QueuedTelemetryClient extends BaseTelemetryClient {
10+
protected queueManager: TelemetryQueueManager | null = null
11+
protected clientId: string
12+
private retryTimer: NodeJS.Timeout | null = null
13+
private isOnline = true
14+
private readonly RETRY_CHECK_INTERVAL = 30000 // Check for retries every 30 seconds
15+
16+
constructor(clientId: string, storagePath: string, subscription?: TelemetryEventSubscription, debug = false) {
17+
super(subscription, debug)
18+
this.clientId = clientId
19+
20+
// Initialize queue manager
21+
try {
22+
this.queueManager = TelemetryQueueManager.getInstance(storagePath)
23+
this.startRetryTimer()
24+
} catch (error) {
25+
console.error(`Failed to initialize queue manager: ${error}`)
26+
}
27+
}
28+
29+
/**
30+
* Capture an event with automatic queuing on failure
31+
*/
32+
public async capture(event: TelemetryEvent): Promise<void> {
33+
if (!this.isTelemetryEnabled() || !this.isEventCapturable(event.event)) {
34+
if (this.debug) {
35+
console.info(`[${this.clientId}#capture] Skipping event: ${event.event}`)
36+
}
37+
return
38+
}
39+
40+
try {
41+
// Try to send the event
42+
if (this.debug) {
43+
console.info(`[${this.clientId}#capture] Attempting to send: ${event.event}`)
44+
}
45+
await this.sendEvent(event)
46+
47+
// If successful and we have queued events, try to process them
48+
if (this.queueManager && this.isOnline) {
49+
if (this.debug) {
50+
console.info(`[${this.clientId}#capture] Send successful, checking for queued events`)
51+
}
52+
this.processQueuedEvents()
53+
}
54+
} catch (error) {
55+
// Queue the event for retry
56+
if (this.queueManager) {
57+
if (this.debug) {
58+
console.info(
59+
`[${this.clientId}#capture] Send failed, queuing event: ${event.event}, error: ${error}`,
60+
)
61+
}
62+
this.queueManager.enqueue(event, this.clientId)
63+
this.isOnline = false
64+
}
65+
66+
// Re-throw if no queue manager (maintains original behavior)
67+
if (!this.queueManager) {
68+
throw error
69+
}
70+
}
71+
}
72+
73+
/**
74+
* Abstract method that subclasses must implement to actually send the event
75+
*/
76+
protected abstract sendEvent(event: TelemetryEvent): Promise<void>
77+
78+
/**
79+
* Process queued events
80+
*/
81+
private async processQueuedEvents(): Promise<void> {
82+
if (!this.queueManager) {
83+
return
84+
}
85+
86+
const eventsToRetry = this.queueManager.getEventsForRetry(this.clientId)
87+
88+
if (eventsToRetry.length === 0) {
89+
return
90+
}
91+
92+
if (this.debug) {
93+
console.info(`[${this.clientId}] Processing ${eventsToRetry.length} queued events`)
94+
}
95+
96+
await this.queueManager.processQueue(this.clientId, async (event) => {
97+
if (this.debug) {
98+
console.info(`[${this.clientId}] Retrying queued event: ${event.event}`)
99+
}
100+
await this.sendEvent(event)
101+
this.isOnline = true
102+
if (this.debug) {
103+
console.info(`[${this.clientId}] Successfully sent queued event, marking online`)
104+
}
105+
})
106+
}
107+
108+
/**
109+
* Start the retry timer
110+
*/
111+
private startRetryTimer(): void {
112+
if (this.debug) {
113+
console.info(`[${this.clientId}] Starting retry timer, checking every ${this.RETRY_CHECK_INTERVAL}ms`)
114+
}
115+
this.retryTimer = setInterval(() => {
116+
if (this.debug) {
117+
console.info(`[${this.clientId}] Retry timer triggered, checking for events to retry`)
118+
}
119+
this.processQueuedEvents()
120+
}, this.RETRY_CHECK_INTERVAL)
121+
}
122+
123+
/**
124+
* Stop the retry timer
125+
*/
126+
private stopRetryTimer(): void {
127+
if (this.retryTimer) {
128+
clearInterval(this.retryTimer)
129+
this.retryTimer = null
130+
}
131+
}
132+
133+
/**
134+
* Get queue statistics for this client
135+
*/
136+
public getQueueStats(): { queueSize: number; oldestEventAge: number | null } | null {
137+
if (!this.queueManager) {
138+
return null
139+
}
140+
141+
const stats = this.queueManager.getStats()
142+
const clientEventCount = stats.eventsByClient[this.clientId] || 0
143+
144+
return {
145+
queueSize: clientEventCount,
146+
oldestEventAge: stats.oldestEventAge,
147+
}
148+
}
149+
150+
/**
151+
* Shutdown the client and persist any queued events
152+
*/
153+
public async shutdown(): Promise<void> {
154+
this.stopRetryTimer()
155+
156+
// Try to send any remaining queued events one last time
157+
if (this.queueManager) {
158+
await this.processQueuedEvents()
159+
}
160+
}
161+
}

0 commit comments

Comments
 (0)