Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ go—and, importantly, where they don't.
- **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI
model), it is stored locally on your device and never sent to us or any third
party, except the provider you have chosen.
- **Telemetry (Usage Data)**: We collect feature usage and error data to help
us improve Zoo Code. This telemetry is powered by PostHog and includes your
VS Code machine ID, feature usage patterns, and exception reports. The VS Code
- **Telemetry (Usage Data)**: We collect feature usage and error data to help us
improve Zoo Code. This telemetry is powered by PostHog and includes your VS
Code machine ID, feature usage patterns, and exception reports. The VS Code
machine ID is a persistent identifier and may be considered personal data in
some jurisdictions; we use it only for product analytics and error grouping.
We retain telemetry only as long as needed for product analytics and debugging.
Telemetry does **not** collect your code or AI prompts, and you can opt out at
any time through the settings.
We retain telemetry only as long as needed for product analytics and
debugging. This PostHog-based telemetry does **not** collect your code or AI
prompts, and you can opt out at any time through the settings.
- **Marketplace Requests**: When you browse or search the Marketplace for Model
Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API
call to Zoo Code's backend servers to retrieve listing information. These
Expand Down
10 changes: 9 additions & 1 deletion packages/telemetry/src/PostHogTelemetryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@ export class PostHogTelemetryClient extends BaseTelemetryClient {
super(
{
type: "exclude",
events: [TelemetryEventName.TASK_MESSAGE, TelemetryEventName.LLM_COMPLETION],
events: [
TelemetryEventName.TASK_MESSAGE,
TelemetryEventName.LLM_COMPLETION,
// Per-turn events superseded by the toolsUsed/messageCount summary on
// Task Completed (see TelemetryService.captureTaskCompleted). Excluded
// here as a backstop in case any call site still fires them directly.
TelemetryEventName.TASK_CONVERSATION_MESSAGE,
TelemetryEventName.TOOL_USED,
],
},
debug,
)
Expand Down
108 changes: 103 additions & 5 deletions packages/telemetry/src/TelemetryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,53 @@ import {
type TelemetryPropertiesProvider,
TelemetryEventName,
type TelemetrySetting,
type ToolUsage,
} from "@roo-code/types"

/**
* Events prone to retry-storm-style repetition (e.g. a broken embedder config
* re-triggering on every file-system event). Guarded by a circuit breaker in
* `captureEvent` so a single broken install can't flood the Product Analytics
* quota. Tracked per-event via a sliding time window, independent of any
* other telemetry the same install may also be sending.
*/
const CIRCUIT_BREAKER_GUARDED_EVENTS = new Set<TelemetryEventName>([TelemetryEventName.CODE_INDEX_ERROR])

/** Captures of a guarded event within the counting window allowed before the breaker trips. */
const CIRCUIT_BREAKER_MAX_IN_WINDOW = 50

/** Rolling window over which guarded-event occurrences are counted. */
const CIRCUIT_BREAKER_WINDOW_MS = 10 * 60 * 1000

/** How long a tripped breaker stays tripped before allowing captures again. */
const CIRCUIT_BREAKER_COOLDOWN_MS = 10 * 60 * 1000

/**
* TelemetryService wrapper class that defers initialization.
* This ensures that we only create the various clients after environment
* variables are loaded.
*/
export class TelemetryService {
// Timestamps of recent guarded-event occurrences, per event name, oldest first.
private guardedEventOccurrences = new Map<TelemetryEventName, number[]>()
private trippedUntil = new Map<TelemetryEventName, number>()

// In-flight client.capture()/captureException() promises. captureEvent/captureException are
// synchronous (void-returning) for callers, but the underlying client calls are async (e.g.
// PostHogTelemetryClient awaits property enrichment before enqueueing). Tracked here so
// shutdown() can drain them before flushing/closing the clients -- otherwise a capture that's
// still mid-flight when shutdown() runs could be lost entirely.
private pendingClientCalls = new Set<Promise<unknown>>()

constructor(private clients: TelemetryClient[]) {}

private trackPendingClientCall(promise: Promise<unknown>): void {
// Never let a rejected client call surface as an unhandled rejection or block shutdown.
const tracked = promise.catch(() => undefined)
this.pendingClientCalls.add(tracked)
void tracked.finally(() => this.pendingClientCalls.delete(tracked))
}

public register(client: TelemetryClient): void {
this.clients.push(client)
}
Expand Down Expand Up @@ -51,6 +88,44 @@ export class TelemetryService {
this.clients.forEach((client) => client.updateTelemetryState(isOptedIn))
}

/**
* Checks whether a guarded event should be dropped by the circuit breaker,
* updating the breaker's internal state as a side effect. Tracked entirely
* independently of other event names -- unrelated telemetry from the same
* install must never mask (or count towards) a guarded-event burst.
*/
private shouldDropForCircuitBreaker(eventName: TelemetryEventName): boolean {
if (!CIRCUIT_BREAKER_GUARDED_EVENTS.has(eventName)) {
return false
}

const now = Date.now()

const trippedUntil = this.trippedUntil.get(eventName)
if (trippedUntil !== undefined) {
if (now < trippedUntil) {
return true
}

// Cooldown elapsed - reset and allow this capture through.
this.trippedUntil.delete(eventName)
this.guardedEventOccurrences.delete(eventName)
}

const windowStart = now - CIRCUIT_BREAKER_WINDOW_MS
const occurrences = (this.guardedEventOccurrences.get(eventName) ?? []).filter((ts) => ts > windowStart)
occurrences.push(now)
this.guardedEventOccurrences.set(eventName, occurrences)

if (occurrences.length > CIRCUIT_BREAKER_MAX_IN_WINDOW) {
this.trippedUntil.set(eventName, now + CIRCUIT_BREAKER_COOLDOWN_MS)
this.guardedEventOccurrences.delete(eventName)
return true
}

return false
}

/**
* Generic method to capture any type of event with specified properties
* @param eventName The event name to capture
Expand All @@ -62,7 +137,11 @@ export class TelemetryService {
return
}

this.clients.forEach((client) => client.capture({ event: eventName, properties }))
if (this.shouldDropForCircuitBreaker(eventName)) {
return
}

this.clients.forEach((client) => this.trackPendingClientCall(client.capture({ event: eventName, properties })))
}

/**
Expand All @@ -75,7 +154,9 @@ export class TelemetryService {
return
}

this.clients.forEach((client) => client.captureException(error, additionalProperties))
this.clients.forEach((client) =>
this.trackPendingClientCall(client.captureException(error, additionalProperties)),
)
}

public captureTaskCreated(taskId: string): void {
Expand All @@ -86,8 +167,21 @@ export class TelemetryService {
this.captureEvent(TelemetryEventName.TASK_RESTARTED, { taskId })
}

public captureTaskCompleted(taskId: string): void {
this.captureEvent(TelemetryEventName.TASK_COMPLETED, { taskId })
/**
* Captures task completion, optionally summarizing the per-task tool and
* message counts that were previously reported as separate per-turn events
* (`Tool Used`, `Conversation Message`) to reduce Product Analytics volume.
*/
public captureTaskCompleted(
taskId: string,
toolsUsed?: ToolUsage,
messageCount?: { user: number; assistant: number },
): void {
this.captureEvent(TelemetryEventName.TASK_COMPLETED, {
taskId,
...(toolsUsed !== undefined && { toolsUsed }),
...(messageCount !== undefined && { messageCount }),
})
}

public captureConversationMessage(taskId: string, source: "user" | "assistant"): void {
Expand Down Expand Up @@ -263,7 +357,11 @@ export class TelemetryService {
return
}

this.clients.forEach((client) => client.shutdown())
// Drain any in-flight capture/captureException calls first, so a client's shutdown()
// (which flushes its queue) can't run ahead of a capture that hasn't been enqueued yet.
await Promise.all(this.pendingClientCalls)

await Promise.all(this.clients.map((client) => client.shutdown()))
}

private static _instance: TelemetryService | null = null
Expand Down
12 changes: 12 additions & 0 deletions packages/telemetry/src/__tests__/PostHogTelemetryClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ describe("PostHogTelemetryClient", () => {

expect(isEventCapturable(TelemetryEventName.LLM_COMPLETION)).toBe(false)
})

it("should exclude per-turn Conversation Message and Tool Used events (superseded by Task Completed summaries)", () => {
const client = new PostHogTelemetryClient()

const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
client,
"isEventCapturable",
).bind(client)

expect(isEventCapturable(TelemetryEventName.TASK_CONVERSATION_MESSAGE)).toBe(false)
expect(isEventCapturable(TelemetryEventName.TOOL_USED)).toBe(false)
})
})

describe("isPropertyCapturable", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// pnpm --filter @roo-code/telemetry test src/__tests__/TelemetryService.circuit-breaker.test.ts

import { TelemetryEventName, type TelemetryClient } from "@roo-code/types"

import { TelemetryService } from "../TelemetryService"

describe("TelemetryService circuit breaker", () => {
let mockClient: TelemetryClient

beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0)

mockClient = {
setProvider: vi.fn(),
capture: vi.fn().mockResolvedValue(undefined),
captureException: vi.fn().mockResolvedValue(undefined),
updateTelemetryState: vi.fn(),
isTelemetryEnabled: vi.fn().mockReturnValue(true),
shutdown: vi.fn().mockResolvedValue(undefined),
}
})

afterEach(() => {
vi.useRealTimers()
})

it("passes through captures under the trip threshold", () => {
const service = new TelemetryService([mockClient])

for (let i = 0; i < 49; i++) {
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
}

expect(mockClient.capture).toHaveBeenCalledTimes(49)
})

it("trips after 50 CODE_INDEX_ERROR captures within the window and drops further ones", () => {
const service = new TelemetryService([mockClient])

for (let i = 0; i < 50; i++) {
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
}
expect(mockClient.capture).toHaveBeenCalledTimes(50)

// 51st capture should be dropped - breaker has tripped.
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
expect(mockClient.capture).toHaveBeenCalledTimes(50)

// Keeps dropping while tripped.
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 51 })
expect(mockClient.capture).toHaveBeenCalledTimes(50)
})

it("re-allows captures after the cooldown window elapses", () => {
const service = new TelemetryService([mockClient])

for (let i = 0; i < 50; i++) {
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
}
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
expect(mockClient.capture).toHaveBeenCalledTimes(50)

// Just under 10 minutes - still tripped.
vi.setSystemTime(10 * 60 * 1000 - 1)
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 51 })
expect(mockClient.capture).toHaveBeenCalledTimes(50)

// Cooldown elapsed - one more error gets through.
vi.setSystemTime(10 * 60 * 1000)
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 52 })
expect(mockClient.capture).toHaveBeenCalledTimes(51)
})

it("does not reset the guarded count when unrelated events are interleaved", () => {
// A real broken install still does normal things (creates/completes other tasks)
// while a subsystem like code-index is stuck in a retry loop. Unrelated telemetry
// must not mask the CODE_INDEX_ERROR burst by resetting its count.
const service = new TelemetryService([mockClient])

for (let i = 0; i < 25; i++) {
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: `task-${i}` })
}
// 25 CODE_INDEX_ERROR so far - still under the threshold of 50.
expect(mockClient.capture).toHaveBeenCalledTimes(25 + 25)

for (let i = 25; i < 50; i++) {
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: `task-${i}` })
}
// 50th CODE_INDEX_ERROR trips the breaker; TASK_CREATED events are never guarded.
expect(mockClient.capture).toHaveBeenCalledTimes(50 + 50)

// Further CODE_INDEX_ERROR captures are dropped even though unrelated events keep flowing.
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: "task-50" })
expect(mockClient.capture).toHaveBeenCalledTimes(50 + 51)
})

it("expires old occurrences outside the counting window instead of trapping the breaker open forever", () => {
// A slow trickle of CODE_INDEX_ERROR (below the burst rate) should never trip the
// breaker, since old occurrences age out of the window rather than accumulating forever.
const service = new TelemetryService([mockClient])

for (let i = 0; i < 60; i++) {
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i })
// Advance well past the counting window between each one.
vi.setSystemTime(Date.now() + 60 * 1000)
}

expect(mockClient.capture).toHaveBeenCalledTimes(60)
})

it("does not guard other event names", () => {
const service = new TelemetryService([mockClient])

for (let i = 0; i < 200; i++) {
service.captureEvent(TelemetryEventName.TOOL_USED, { tool: "read_file" })
}

expect(mockClient.capture).toHaveBeenCalledTimes(200)
})
})
Loading
Loading