Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 45dbe4d

Browse files
authored
feat(telemetry): extract error messages from JSON payloads for better PostHog grouping (#10163)
1 parent 495664c commit 45dbe4d

2 files changed

Lines changed: 197 additions & 6 deletions

File tree

packages/types/src/__tests__/telemetry.test.ts

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import {
44
getErrorStatusCode,
55
getErrorMessage,
6+
extractMessageFromJsonPayload,
67
shouldReportApiErrorToTelemetry,
78
EXPECTED_API_ERROR_CODES,
89
ApiProviderError,
@@ -48,17 +49,22 @@ describe("telemetry error utilities", () => {
4849
})
4950

5051
describe("getErrorMessage", () => {
51-
it("should return undefined for non-OpenAI SDK errors", () => {
52+
it("should return undefined for null, undefined, or objects without message", () => {
5253
expect(getErrorMessage(null)).toBeUndefined()
5354
expect(getErrorMessage(undefined)).toBeUndefined()
54-
expect(getErrorMessage({ message: "error" })).toBeUndefined()
55+
expect(getErrorMessage({})).toBeUndefined()
56+
expect(getErrorMessage({ code: 500 })).toBeUndefined()
5557
})
5658

5759
it("should return the primary message for simple OpenAI SDK errors", () => {
5860
const error = { status: 400, message: "Bad request" }
5961
expect(getErrorMessage(error)).toBe("Bad request")
6062
})
6163

64+
it("should return message from plain objects with message property", () => {
65+
expect(getErrorMessage({ message: "error" })).toBe("error")
66+
})
67+
6268
it("should prioritize nested error.message over primary message", () => {
6369
const error = {
6470
status: 500,
@@ -100,6 +106,132 @@ describe("telemetry error utilities", () => {
100106
}
101107
expect(getErrorMessage(error)).toBe("Forbidden")
102108
})
109+
110+
it("should extract message from JSON payload in error message", () => {
111+
const error = {
112+
status: 503,
113+
message: '503 {"error":{"code":"","message":"Model unavailable"}}',
114+
}
115+
expect(getErrorMessage(error)).toBe("Model unavailable")
116+
})
117+
118+
it("should extract message from JSON payload with status prefix", () => {
119+
const error = {
120+
status: 503,
121+
message:
122+
'503 {"error":{"code":"","message":"所有令牌分组 Tier 3 下对于模型 claude-sonnet-4-5 均无可用渠道,请更换分组尝试"}}',
123+
}
124+
expect(getErrorMessage(error)).toBe(
125+
"所有令牌分组 Tier 3 下对于模型 claude-sonnet-4-5 均无可用渠道,请更换分组尝试",
126+
)
127+
})
128+
129+
it("should extract message from nested error.message containing JSON", () => {
130+
const error = {
131+
status: 500,
132+
message: "Request failed",
133+
error: { message: '{"error":{"message":"Upstream provider error"}}' },
134+
}
135+
expect(getErrorMessage(error)).toBe("Upstream provider error")
136+
})
137+
138+
it("should return original message when JSON has no message field", () => {
139+
const error = {
140+
status: 500,
141+
message: '{"error":{"code":"123"}}',
142+
}
143+
expect(getErrorMessage(error)).toBe('{"error":{"code":"123"}}')
144+
})
145+
146+
it("should return original message when JSON is invalid", () => {
147+
const error = {
148+
status: 500,
149+
message: "503 {invalid json}",
150+
}
151+
expect(getErrorMessage(error)).toBe("503 {invalid json}")
152+
})
153+
154+
it("should extract message from standard Error object", () => {
155+
const error = new Error("Simple error message")
156+
expect(getErrorMessage(error)).toBe("Simple error message")
157+
})
158+
159+
it("should extract message from standard Error with JSON payload", () => {
160+
const error = new Error('503 {"error":{"code":"","message":"Model unavailable"}}')
161+
expect(getErrorMessage(error)).toBe("Model unavailable")
162+
})
163+
164+
it("should extract message from ApiProviderError", () => {
165+
const error = new ApiProviderError("Test error", "OpenRouter", "gpt-4", "createMessage")
166+
expect(getErrorMessage(error)).toBe("Test error")
167+
})
168+
169+
it("should extract message from ApiProviderError with JSON payload", () => {
170+
const jsonMessage =
171+
'503 {"error":{"code":"","message":"所有令牌分组 Tier 3 下对于模型 claude-sonnet-4-5 均无可用渠道"}}'
172+
const error = new ApiProviderError(jsonMessage, "Anthropic", "claude-sonnet-4-5", "createMessage")
173+
expect(getErrorMessage(error)).toBe("所有令牌分组 Tier 3 下对于模型 claude-sonnet-4-5 均无可用渠道")
174+
})
175+
176+
it("should handle ApiProviderError with errorCode but no status property", () => {
177+
const error = new ApiProviderError("Test error", "Anthropic", "claude-3-opus", "createMessage", 500)
178+
expect(getErrorMessage(error)).toBe("Test error")
179+
})
180+
})
181+
182+
describe("extractMessageFromJsonPayload", () => {
183+
it("should return undefined for messages without JSON", () => {
184+
expect(extractMessageFromJsonPayload("Simple error message")).toBeUndefined()
185+
expect(extractMessageFromJsonPayload("Error: something went wrong")).toBeUndefined()
186+
expect(extractMessageFromJsonPayload("")).toBeUndefined()
187+
})
188+
189+
it("should extract message from error.message structure", () => {
190+
const json = '{"error":{"message":"Model unavailable"}}'
191+
expect(extractMessageFromJsonPayload(json)).toBe("Model unavailable")
192+
})
193+
194+
it("should extract message from error.message with code structure", () => {
195+
const json = '{"error":{"code":"","message":"Model unavailable"}}'
196+
expect(extractMessageFromJsonPayload(json)).toBe("Model unavailable")
197+
})
198+
199+
it("should extract message from status prefix followed by JSON", () => {
200+
const message = '503 {"error":{"code":"","message":"Model unavailable"}}'
201+
expect(extractMessageFromJsonPayload(message)).toBe("Model unavailable")
202+
})
203+
204+
it("should extract message from simple message structure", () => {
205+
const json = '{"message":"Simple error"}'
206+
expect(extractMessageFromJsonPayload(json)).toBe("Simple error")
207+
})
208+
209+
it("should return undefined for JSON without message field", () => {
210+
const json = '{"error":{"code":"500"}}'
211+
expect(extractMessageFromJsonPayload(json)).toBeUndefined()
212+
})
213+
214+
it("should return undefined for invalid JSON", () => {
215+
expect(extractMessageFromJsonPayload("{invalid json}")).toBeUndefined()
216+
expect(extractMessageFromJsonPayload("503 {not: valid: json}")).toBeUndefined()
217+
})
218+
219+
it("should handle nested error structure with empty code", () => {
220+
const json = '{"error":{"code":"","message":"Token quota exceeded"}}'
221+
expect(extractMessageFromJsonPayload(json)).toBe("Token quota exceeded")
222+
})
223+
224+
it("should handle Unicode messages correctly", () => {
225+
const json = '{"error":{"message":"所有令牌分组 Tier 3 下对于模型 claude-sonnet-4-5 均无可用渠道"}}'
226+
expect(extractMessageFromJsonPayload(json)).toBe(
227+
"所有令牌分组 Tier 3 下对于模型 claude-sonnet-4-5 均无可用渠道",
228+
)
229+
})
230+
231+
it("should return undefined when message field is not a string", () => {
232+
const json = '{"error":{"message":123}}'
233+
expect(extractMessageFromJsonPayload(json)).toBeUndefined()
234+
})
103235
})
104236

105237
describe("shouldReportApiErrorToTelemetry", () => {

packages/types/src/telemetry.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -337,17 +337,76 @@ export function getErrorStatusCode(error: unknown): number | undefined {
337337
}
338338

339339
/**
340-
* Extracts the most descriptive error message from an OpenAI SDK error.
340+
* Extracts a message from a JSON payload embedded in an error string.
341+
* Handles cases like "503 {"error":{"message":"actual error message"}}"
342+
* or just '{"error":{"message":"actual error message"}}'
343+
*
344+
* @param message - The message string that may contain JSON
345+
* @returns The extracted message from the JSON payload, or undefined if not found
346+
*/
347+
export function extractMessageFromJsonPayload(message: string): string | undefined {
348+
// Find the first occurrence of '{' which may indicate JSON content
349+
const jsonStartIndex = message.indexOf("{")
350+
if (jsonStartIndex === -1) {
351+
return undefined
352+
}
353+
354+
const potentialJson = message.slice(jsonStartIndex)
355+
356+
try {
357+
const parsed = JSON.parse(potentialJson)
358+
359+
// Handle structure: {"error":{"message":"..."}} or {"error":{"code":"","message":"..."}}
360+
if (parsed?.error?.message && typeof parsed.error.message === "string") {
361+
return parsed.error.message
362+
}
363+
364+
// Handle structure: {"message":"..."}
365+
if (parsed?.message && typeof parsed.message === "string") {
366+
return parsed.message
367+
}
368+
} catch {
369+
// JSON parsing failed - not valid JSON
370+
}
371+
372+
return undefined
373+
}
374+
375+
/**
376+
* Extracts the most descriptive error message from an error object.
341377
* Prioritizes nested metadata (upstream provider errors) over the standard message.
378+
* Also handles JSON payloads embedded in error messages.
342379
* @param error - The error to extract message from
343-
* @returns The best available error message, or undefined if not an OpenAI SDK error
380+
* @returns The best available error message, or undefined if not extractable
344381
*/
345382
export function getErrorMessage(error: unknown): string | undefined {
383+
let message: string | undefined
384+
346385
if (isOpenAISdkError(error)) {
347386
// Prioritize nested metadata which may contain upstream provider details
348-
return error.error?.metadata?.raw || error.error?.message || error.message
387+
message = error.error?.metadata?.raw || error.error?.message || error.message
388+
} else if (error instanceof Error) {
389+
// Handle standard Error objects (including ApiProviderError)
390+
message = error.message
391+
} else if (typeof error === "object" && error !== null && "message" in error) {
392+
// Handle plain objects with a message property
393+
const msgValue = (error as { message: unknown }).message
394+
if (typeof msgValue === "string") {
395+
message = msgValue
396+
}
349397
}
350-
return undefined
398+
399+
if (!message) {
400+
return undefined
401+
}
402+
403+
// If the message contains JSON, try to extract the message from it
404+
const extractedMessage = extractMessageFromJsonPayload(message)
405+
if (extractedMessage) {
406+
return extractedMessage
407+
}
408+
409+
return message
351410
}
352411

353412
/**

0 commit comments

Comments
 (0)