feat: add API error telemetry to OpenRouter provider (#9953)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Daniel 2025-12-09 19:30:29 -05:00 committed by GitHub
parent 29d6f6d281
commit 24eb6ae984
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 215 additions and 5 deletions

View file

@ -69,6 +69,10 @@ abstract class BaseTelemetryClient implements TelemetryClient {
public abstract capture(event: TelemetryEvent): Promise<void>
public captureException(_error: Error, _additionalProperties?: Record<string, unknown>): void {
// No-op - exception capture is only supported by PostHog
}
public setProvider(provider: TelemetryPropertiesProvider): void {
this.providerRef = new WeakRef(provider)
}

View file

@ -59,6 +59,8 @@ export abstract class BaseTelemetryClient implements TelemetryClient {
public abstract capture(event: TelemetryEvent): Promise<void>
public abstract captureException(error: Error, additionalProperties?: Record<string, unknown>): void
public setProvider(provider: TelemetryPropertiesProvider): void {
this.providerRef = new WeakRef(provider)
}

View file

@ -61,6 +61,22 @@ export class PostHogTelemetryClient extends BaseTelemetryClient {
})
}
public override captureException(error: Error, additionalProperties?: Record<string, unknown>): void {
if (!this.isTelemetryEnabled()) {
if (this.debug) {
console.info(`[PostHogTelemetryClient#captureException] Skipping exception: ${error.message}`)
}
return
}
if (this.debug) {
console.info(`[PostHogTelemetryClient#captureException] ${error.message}`)
}
this.client.captureException(error, this.distinctId, additionalProperties)
}
/**
* Updates the telemetry state based on user preferences and VSCode settings.
* Only enables telemetry if both VSCode global telemetry is enabled and

View file

@ -65,6 +65,19 @@ export class TelemetryService {
this.clients.forEach((client) => client.capture({ event: eventName, properties }))
}
/**
* Captures an exception using PostHog's error tracking
* @param error The error to capture
* @param additionalProperties Additional properties to include with the exception
*/
public captureException(error: Error, additionalProperties?: Record<string, unknown>): void {
if (!this.isReady) {
return
}
this.clients.forEach((client) => client.captureException(error, additionalProperties))
}
public captureTaskCreated(taskId: string): void {
this.captureEvent(TelemetryEventName.TASK_CREATED, { taskId })
}

View file

@ -262,7 +262,44 @@ export interface TelemetryClient {
setProvider(provider: TelemetryPropertiesProvider): void
capture(options: TelemetryEvent): Promise<void>
captureException(error: Error, additionalProperties?: Record<string, unknown>): void
updateTelemetryState(isOptedIn: boolean): void
isTelemetryEnabled(): boolean
shutdown(): Promise<void>
}
/**
* Expected API error codes that should not be reported to telemetry.
* These are normal/expected errors that users can't do much about.
*/
export const EXPECTED_API_ERROR_CODES = new Set([
429, // Rate limit - expected when hitting API limits
])
/**
* Helper to check if an API error should be reported to telemetry.
* Filters out expected errors like rate limits.
* @param errorCode - The HTTP error code (if available)
* @returns true if the error should be reported, false if it should be filtered out
*/
export function shouldReportApiErrorToTelemetry(errorCode?: number): boolean {
if (errorCode === undefined) return true
return !EXPECTED_API_ERROR_CODES.has(errorCode)
}
/**
* Generic API provider error class for structured error tracking via PostHog.
* Can be reused by any API provider.
*/
export class ApiProviderError extends Error {
constructor(
message: string,
public readonly provider: string,
public readonly modelId: string,
public readonly operation: string,
public readonly errorCode?: number,
) {
super(message)
this.name = "ApiProviderError"
}
}

View file

@ -9,10 +9,21 @@ import OpenAI from "openai"
import { OpenRouterHandler } from "../openrouter"
import { ApiHandlerOptions } from "../../../shared/api"
import { Package } from "../../../shared/package"
import { ApiProviderError } from "@roo-code/types"
// Mock dependencies
vitest.mock("openai")
vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) }))
// Mock TelemetryService
const mockCaptureException = vitest.fn()
vitest.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureException: (...args: unknown[]) => mockCaptureException(...args),
},
},
}))
vitest.mock("../fetchers/modelCache", () => ({
getModels: vitest.fn().mockImplementation(() => {
return Promise.resolve({
@ -267,7 +278,7 @@ describe("OpenRouterHandler", () => {
)
})
it("handles API errors", async () => {
it("handles API errors and captures telemetry", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockStream = {
async *[Symbol.asyncIterator]() {
@ -282,6 +293,52 @@ describe("OpenRouterHandler", () => {
const generator = handler.createMessage("test", [])
await expect(generator.next()).rejects.toThrow("OpenRouter API Error 500: API Error")
// Verify telemetry was captured
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(ApiProviderError), {
provider: "OpenRouter",
modelId: mockOptions.openRouterModelId,
operation: "createMessage",
errorCode: 500,
})
})
it("captures telemetry when createMessage throws an exception", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockCreate = vitest.fn().mockRejectedValue(new Error("Connection failed"))
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
const generator = handler.createMessage("test", [])
await expect(generator.next()).rejects.toThrow()
// Verify telemetry was captured
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(ApiProviderError), {
provider: "OpenRouter",
modelId: mockOptions.openRouterModelId,
operation: "createMessage",
})
})
it("does NOT capture telemetry for 429 rate limit errors", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockStream = {
async *[Symbol.asyncIterator]() {
yield { error: { message: "Rate limit exceeded", code: 429 } }
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
const generator = handler.createMessage("test", [])
await expect(generator.next()).rejects.toThrow("OpenRouter API Error 429: Rate limit exceeded")
// Verify telemetry was NOT captured for 429 errors
expect(mockCaptureException).not.toHaveBeenCalled()
})
it("yields tool_call_end events when finish_reason is tool_calls", async () => {
@ -384,7 +441,7 @@ describe("OpenRouterHandler", () => {
)
})
it("handles API errors", async () => {
it("handles API errors and captures telemetry", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockError = {
error: {
@ -399,9 +456,17 @@ describe("OpenRouterHandler", () => {
} as any
await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenRouter API Error 500: API Error")
// Verify telemetry was captured
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(ApiProviderError), {
provider: "OpenRouter",
modelId: mockOptions.openRouterModelId,
operation: "completePrompt",
errorCode: 500,
})
})
it("handles unexpected errors", async () => {
it("handles unexpected errors and captures telemetry", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockCreate = vitest.fn().mockRejectedValue(new Error("Unexpected error"))
;(OpenAI as any).prototype.chat = {
@ -409,6 +474,35 @@ describe("OpenRouterHandler", () => {
} as any
await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error")
// Verify telemetry was captured
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(ApiProviderError), {
provider: "OpenRouter",
modelId: mockOptions.openRouterModelId,
operation: "completePrompt",
})
})
it("does NOT capture telemetry for 429 rate limit errors", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockError = {
error: {
message: "Rate limit exceeded",
code: 429,
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockError)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
"OpenRouter API Error 429: Rate limit exceeded",
)
// Verify telemetry was NOT captured for 429 errors
expect(mockCaptureException).not.toHaveBeenCalled()
})
})
})

View file

@ -7,7 +7,10 @@ import {
OPENROUTER_DEFAULT_PROVIDER_NAME,
OPEN_ROUTER_PROMPT_CACHING_MODELS,
DEEP_SEEK_DEFAULT_TEMPERATURE,
shouldReportApiErrorToTelemetry,
ApiProviderError,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
@ -224,6 +227,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
try {
stream = await this.client.chat.completions.create(completionParams, requestOptions)
} catch (error) {
TelemetryService.instance.captureException(
new ApiProviderError(
error instanceof Error ? error.message : String(error),
this.providerName,
modelId,
"createMessage",
),
{ provider: this.providerName, modelId, operation: "createMessage" },
)
throw handleOpenAIError(error, this.providerName)
}
@ -248,6 +260,18 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
if ("error" in chunk) {
const error = chunk.error as { message?: string; code?: number }
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
if (shouldReportApiErrorToTelemetry(error?.code)) {
TelemetryService.instance.captureException(
new ApiProviderError(
error?.message ?? "Unknown error",
this.providerName,
modelId,
"createMessage",
error?.code,
),
{ provider: this.providerName, modelId, operation: "createMessage", errorCode: error?.code },
)
}
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
}
@ -442,11 +466,32 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
try {
response = await this.client.chat.completions.create(completionParams, requestOptions)
} catch (error) {
TelemetryService.instance.captureException(
new ApiProviderError(
error instanceof Error ? error.message : String(error),
this.providerName,
modelId,
"completePrompt",
),
{ provider: this.providerName, modelId, operation: "completePrompt" },
)
throw handleOpenAIError(error, this.providerName)
}
if ("error" in response) {
const error = response.error as { message?: string; code?: number }
if (shouldReportApiErrorToTelemetry(error?.code)) {
TelemetryService.instance.captureException(
new ApiProviderError(
error?.message ?? "Unknown error",
this.providerName,
modelId,
"completePrompt",
error?.code,
),
{ provider: this.providerName, modelId, operation: "completePrompt", errorCode: error?.code },
)
}
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
}

View file

@ -42,8 +42,6 @@ import { Task } from "../task/Task"
import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
import { experiments, EXPERIMENT_IDS } from "../../shared/experiments"
import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool"
import { isNativeProtocol } from "@roo-code/types"
import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
/**
* Processes and presents assistant message content to the user interface.

View file

@ -849,6 +849,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
role: "user",
content: this.userMessageContent,
}
const userMessageWithTs = { ...userMessage, ts: Date.now() }
this.apiConversationHistory.push(userMessageWithTs as ApiMessage)