Skip to content

Commit 24eb6ae

Browse files
daniel-lxsroomote
andauthored
feat: add API error telemetry to OpenRouter provider (#9953)
Co-authored-by: Roo Code <[email protected]>
1 parent 29d6f6d commit 24eb6ae

File tree

9 files changed

+215
-5
lines changed

9 files changed

+215
-5
lines changed

packages/cloud/src/TelemetryClient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ abstract class BaseTelemetryClient implements TelemetryClient {
6969

7070
public abstract capture(event: TelemetryEvent): Promise<void>
7171

72+
public captureException(_error: Error, _additionalProperties?: Record<string, unknown>): void {
73+
// No-op - exception capture is only supported by PostHog
74+
}
75+
7276
public setProvider(provider: TelemetryPropertiesProvider): void {
7377
this.providerRef = new WeakRef(provider)
7478
}

packages/telemetry/src/BaseTelemetryClient.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export abstract class BaseTelemetryClient implements TelemetryClient {
5959

6060
public abstract capture(event: TelemetryEvent): Promise<void>
6161

62+
public abstract captureException(error: Error, additionalProperties?: Record<string, unknown>): void
63+
6264
public setProvider(provider: TelemetryPropertiesProvider): void {
6365
this.providerRef = new WeakRef(provider)
6466
}

packages/telemetry/src/PostHogTelemetryClient.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,22 @@ export class PostHogTelemetryClient extends BaseTelemetryClient {
6161
})
6262
}
6363

64+
public override captureException(error: Error, additionalProperties?: Record<string, unknown>): void {
65+
if (!this.isTelemetryEnabled()) {
66+
if (this.debug) {
67+
console.info(`[PostHogTelemetryClient#captureException] Skipping exception: ${error.message}`)
68+
}
69+
70+
return
71+
}
72+
73+
if (this.debug) {
74+
console.info(`[PostHogTelemetryClient#captureException] ${error.message}`)
75+
}
76+
77+
this.client.captureException(error, this.distinctId, additionalProperties)
78+
}
79+
6480
/**
6581
* Updates the telemetry state based on user preferences and VSCode settings.
6682
* Only enables telemetry if both VSCode global telemetry is enabled and

packages/telemetry/src/TelemetryService.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ export class TelemetryService {
6565
this.clients.forEach((client) => client.capture({ event: eventName, properties }))
6666
}
6767

68+
/**
69+
* Captures an exception using PostHog's error tracking
70+
* @param error The error to capture
71+
* @param additionalProperties Additional properties to include with the exception
72+
*/
73+
public captureException(error: Error, additionalProperties?: Record<string, unknown>): void {
74+
if (!this.isReady) {
75+
return
76+
}
77+
78+
this.clients.forEach((client) => client.captureException(error, additionalProperties))
79+
}
80+
6881
public captureTaskCreated(taskId: string): void {
6982
this.captureEvent(TelemetryEventName.TASK_CREATED, { taskId })
7083
}

packages/types/src/telemetry.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,44 @@ export interface TelemetryClient {
262262

263263
setProvider(provider: TelemetryPropertiesProvider): void
264264
capture(options: TelemetryEvent): Promise<void>
265+
captureException(error: Error, additionalProperties?: Record<string, unknown>): void
265266
updateTelemetryState(isOptedIn: boolean): void
266267
isTelemetryEnabled(): boolean
267268
shutdown(): Promise<void>
268269
}
270+
271+
/**
272+
* Expected API error codes that should not be reported to telemetry.
273+
* These are normal/expected errors that users can't do much about.
274+
*/
275+
export const EXPECTED_API_ERROR_CODES = new Set([
276+
429, // Rate limit - expected when hitting API limits
277+
])
278+
279+
/**
280+
* Helper to check if an API error should be reported to telemetry.
281+
* Filters out expected errors like rate limits.
282+
* @param errorCode - The HTTP error code (if available)
283+
* @returns true if the error should be reported, false if it should be filtered out
284+
*/
285+
export function shouldReportApiErrorToTelemetry(errorCode?: number): boolean {
286+
if (errorCode === undefined) return true
287+
return !EXPECTED_API_ERROR_CODES.has(errorCode)
288+
}
289+
290+
/**
291+
* Generic API provider error class for structured error tracking via PostHog.
292+
* Can be reused by any API provider.
293+
*/
294+
export class ApiProviderError extends Error {
295+
constructor(
296+
message: string,
297+
public readonly provider: string,
298+
public readonly modelId: string,
299+
public readonly operation: string,
300+
public readonly errorCode?: number,
301+
) {
302+
super(message)
303+
this.name = "ApiProviderError"
304+
}
305+
}

src/api/providers/__tests__/openrouter.spec.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,21 @@ import OpenAI from "openai"
99
import { OpenRouterHandler } from "../openrouter"
1010
import { ApiHandlerOptions } from "../../../shared/api"
1111
import { Package } from "../../../shared/package"
12+
import { ApiProviderError } from "@roo-code/types"
1213

1314
// Mock dependencies
1415
vitest.mock("openai")
1516
vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) }))
17+
18+
// Mock TelemetryService
19+
const mockCaptureException = vitest.fn()
20+
vitest.mock("@roo-code/telemetry", () => ({
21+
TelemetryService: {
22+
instance: {
23+
captureException: (...args: unknown[]) => mockCaptureException(...args),
24+
},
25+
},
26+
}))
1627
vitest.mock("../fetchers/modelCache", () => ({
1728
getModels: vitest.fn().mockImplementation(() => {
1829
return Promise.resolve({
@@ -267,7 +278,7 @@ describe("OpenRouterHandler", () => {
267278
)
268279
})
269280

270-
it("handles API errors", async () => {
281+
it("handles API errors and captures telemetry", async () => {
271282
const handler = new OpenRouterHandler(mockOptions)
272283
const mockStream = {
273284
async *[Symbol.asyncIterator]() {
@@ -282,6 +293,52 @@ describe("OpenRouterHandler", () => {
282293

283294
const generator = handler.createMessage("test", [])
284295
await expect(generator.next()).rejects.toThrow("OpenRouter API Error 500: API Error")
296+
297+
// Verify telemetry was captured
298+
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(ApiProviderError), {
299+
provider: "OpenRouter",
300+
modelId: mockOptions.openRouterModelId,
301+
operation: "createMessage",
302+
errorCode: 500,
303+
})
304+
})
305+
306+
it("captures telemetry when createMessage throws an exception", async () => {
307+
const handler = new OpenRouterHandler(mockOptions)
308+
const mockCreate = vitest.fn().mockRejectedValue(new Error("Connection failed"))
309+
;(OpenAI as any).prototype.chat = {
310+
completions: { create: mockCreate },
311+
} as any
312+
313+
const generator = handler.createMessage("test", [])
314+
await expect(generator.next()).rejects.toThrow()
315+
316+
// Verify telemetry was captured
317+
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(ApiProviderError), {
318+
provider: "OpenRouter",
319+
modelId: mockOptions.openRouterModelId,
320+
operation: "createMessage",
321+
})
322+
})
323+
324+
it("does NOT capture telemetry for 429 rate limit errors", async () => {
325+
const handler = new OpenRouterHandler(mockOptions)
326+
const mockStream = {
327+
async *[Symbol.asyncIterator]() {
328+
yield { error: { message: "Rate limit exceeded", code: 429 } }
329+
},
330+
}
331+
332+
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
333+
;(OpenAI as any).prototype.chat = {
334+
completions: { create: mockCreate },
335+
} as any
336+
337+
const generator = handler.createMessage("test", [])
338+
await expect(generator.next()).rejects.toThrow("OpenRouter API Error 429: Rate limit exceeded")
339+
340+
// Verify telemetry was NOT captured for 429 errors
341+
expect(mockCaptureException).not.toHaveBeenCalled()
285342
})
286343

287344
it("yields tool_call_end events when finish_reason is tool_calls", async () => {
@@ -384,7 +441,7 @@ describe("OpenRouterHandler", () => {
384441
)
385442
})
386443

387-
it("handles API errors", async () => {
444+
it("handles API errors and captures telemetry", async () => {
388445
const handler = new OpenRouterHandler(mockOptions)
389446
const mockError = {
390447
error: {
@@ -399,16 +456,53 @@ describe("OpenRouterHandler", () => {
399456
} as any
400457

401458
await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenRouter API Error 500: API Error")
459+
460+
// Verify telemetry was captured
461+
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(ApiProviderError), {
462+
provider: "OpenRouter",
463+
modelId: mockOptions.openRouterModelId,
464+
operation: "completePrompt",
465+
errorCode: 500,
466+
})
402467
})
403468

404-
it("handles unexpected errors", async () => {
469+
it("handles unexpected errors and captures telemetry", async () => {
405470
const handler = new OpenRouterHandler(mockOptions)
406471
const mockCreate = vitest.fn().mockRejectedValue(new Error("Unexpected error"))
407472
;(OpenAI as any).prototype.chat = {
408473
completions: { create: mockCreate },
409474
} as any
410475

411476
await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error")
477+
478+
// Verify telemetry was captured
479+
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(ApiProviderError), {
480+
provider: "OpenRouter",
481+
modelId: mockOptions.openRouterModelId,
482+
operation: "completePrompt",
483+
})
484+
})
485+
486+
it("does NOT capture telemetry for 429 rate limit errors", async () => {
487+
const handler = new OpenRouterHandler(mockOptions)
488+
const mockError = {
489+
error: {
490+
message: "Rate limit exceeded",
491+
code: 429,
492+
},
493+
}
494+
495+
const mockCreate = vitest.fn().mockResolvedValue(mockError)
496+
;(OpenAI as any).prototype.chat = {
497+
completions: { create: mockCreate },
498+
} as any
499+
500+
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
501+
"OpenRouter API Error 429: Rate limit exceeded",
502+
)
503+
504+
// Verify telemetry was NOT captured for 429 errors
505+
expect(mockCaptureException).not.toHaveBeenCalled()
412506
})
413507
})
414508
})

src/api/providers/openrouter.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import {
77
OPENROUTER_DEFAULT_PROVIDER_NAME,
88
OPEN_ROUTER_PROMPT_CACHING_MODELS,
99
DEEP_SEEK_DEFAULT_TEMPERATURE,
10+
shouldReportApiErrorToTelemetry,
11+
ApiProviderError,
1012
} from "@roo-code/types"
13+
import { TelemetryService } from "@roo-code/telemetry"
1114

1215
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
1316

@@ -224,6 +227,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
224227
try {
225228
stream = await this.client.chat.completions.create(completionParams, requestOptions)
226229
} catch (error) {
230+
TelemetryService.instance.captureException(
231+
new ApiProviderError(
232+
error instanceof Error ? error.message : String(error),
233+
this.providerName,
234+
modelId,
235+
"createMessage",
236+
),
237+
{ provider: this.providerName, modelId, operation: "createMessage" },
238+
)
227239
throw handleOpenAIError(error, this.providerName)
228240
}
229241

@@ -248,6 +260,18 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
248260
if ("error" in chunk) {
249261
const error = chunk.error as { message?: string; code?: number }
250262
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
263+
if (shouldReportApiErrorToTelemetry(error?.code)) {
264+
TelemetryService.instance.captureException(
265+
new ApiProviderError(
266+
error?.message ?? "Unknown error",
267+
this.providerName,
268+
modelId,
269+
"createMessage",
270+
error?.code,
271+
),
272+
{ provider: this.providerName, modelId, operation: "createMessage", errorCode: error?.code },
273+
)
274+
}
251275
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
252276
}
253277

@@ -442,11 +466,32 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
442466
try {
443467
response = await this.client.chat.completions.create(completionParams, requestOptions)
444468
} catch (error) {
469+
TelemetryService.instance.captureException(
470+
new ApiProviderError(
471+
error instanceof Error ? error.message : String(error),
472+
this.providerName,
473+
modelId,
474+
"completePrompt",
475+
),
476+
{ provider: this.providerName, modelId, operation: "completePrompt" },
477+
)
445478
throw handleOpenAIError(error, this.providerName)
446479
}
447480

448481
if ("error" in response) {
449482
const error = response.error as { message?: string; code?: number }
483+
if (shouldReportApiErrorToTelemetry(error?.code)) {
484+
TelemetryService.instance.captureException(
485+
new ApiProviderError(
486+
error?.message ?? "Unknown error",
487+
this.providerName,
488+
modelId,
489+
"completePrompt",
490+
error?.code,
491+
),
492+
{ provider: this.providerName, modelId, operation: "completePrompt", errorCode: error?.code },
493+
)
494+
}
450495
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
451496
}
452497

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,6 @@ import { Task } from "../task/Task"
4242
import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
4343
import { experiments, EXPERIMENT_IDS } from "../../shared/experiments"
4444
import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool"
45-
import { isNativeProtocol } from "@roo-code/types"
46-
import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
4745

4846
/**
4947
* Processes and presents assistant message content to the user interface.

src/core/task/Task.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
849849
role: "user",
850850
content: this.userMessageContent,
851851
}
852+
852853
const userMessageWithTs = { ...userMessage, ts: Date.now() }
853854
this.apiConversationHistory.push(userMessageWithTs as ApiMessage)
854855

0 commit comments

Comments
 (0)