Skip to content

Commit 4b525dc

Browse files
committed
feat: add API inference logging for debugging
- Add ApiInferenceLogger singleton class for logging API requests/responses - Add inferenceLogger property to BaseProvider - Update all providers to use the logger - Configure logger in extension.ts with output channel sink - Add payload sanitization (truncate strings, cap arrays, redact secrets) - Enable via ROO_CODE_API_LOGGING=true environment variable Closes ROO-271
1 parent 9b06a98 commit 4b525dc

27 files changed

Lines changed: 1975 additions & 595 deletions
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
/**
2+
* Lightweight logger for API inference requests/responses.
3+
*
4+
* This logger is designed to capture raw inference inputs/outputs across providers
5+
* for debugging purposes. It emits structured objects to a configurable sink.
6+
*
7+
* For streaming requests, only the final assembled response is logged (not individual chunks).
8+
*
9+
* Enable via environment variable: process.env.ROO_CODE_API_LOGGING === "true"
10+
*/
11+
12+
export interface ApiInferenceLoggerConfig {
13+
enabled: boolean
14+
sink: (...args: unknown[]) => void
15+
}
16+
17+
export interface ApiInferenceContext {
18+
provider: string
19+
operation: string
20+
model?: string
21+
taskId?: string
22+
requestId?: string
23+
}
24+
25+
export interface ApiInferenceHandle {
26+
success: (responsePayload: unknown) => void
27+
error: (errorPayload: unknown) => void
28+
}
29+
30+
/**
31+
* Configuration for payload size limiting to avoid freezing the Output Channel.
32+
*/
33+
const PAYLOAD_LIMITS = {
34+
/** Maximum string length before truncation */
35+
MAX_STRING_LENGTH: 10_000,
36+
/** Maximum array entries to log */
37+
MAX_ARRAY_LENGTH: 200,
38+
/** Maximum object keys to log */
39+
MAX_OBJECT_KEYS: 200,
40+
}
41+
42+
/**
43+
* Regex pattern for detecting base64 image data URLs.
44+
*/
45+
const BASE64_IMAGE_PATTERN = /^data:image\/[^;]+;base64,/
46+
47+
/**
48+
* Secret field patterns to redact in logged payloads.
49+
* Case-insensitive matching is applied.
50+
* Note: Patterns are designed to avoid false positives (e.g., "inputTokens" should not be redacted).
51+
*/
52+
const SECRET_PATTERNS = [
53+
"authorization",
54+
"apikey",
55+
"api_key",
56+
"x-api-key",
57+
"access_token",
58+
"accesstoken",
59+
"bearer",
60+
"secret",
61+
"password",
62+
"credential",
63+
]
64+
65+
/**
66+
* Patterns that indicate a field is NOT a secret (allowlist).
67+
* These are checked before secret patterns to prevent false positives.
68+
*/
69+
const NON_SECRET_PATTERNS = ["inputtokens", "outputtokens", "cachetokens", "reasoningtokens", "totaltokens"]
70+
71+
/**
72+
* Check if a key name looks like a secret field.
73+
*/
74+
function isSecretKey(key: string): boolean {
75+
const lowerKey = key.toLowerCase()
76+
// Check allowlist first to avoid false positives
77+
if (NON_SECRET_PATTERNS.some((pattern) => lowerKey.includes(pattern))) {
78+
return false
79+
}
80+
return SECRET_PATTERNS.some((pattern) => lowerKey.includes(pattern))
81+
}
82+
83+
/**
84+
* Truncate a string if it exceeds the maximum length.
85+
* Also replaces base64 image data with a placeholder.
86+
*/
87+
function sanitizeString(str: string): string {
88+
// Check for base64 image data URLs first
89+
if (BASE64_IMAGE_PATTERN.test(str)) {
90+
return `[ImageData len=${str.length}]`
91+
}
92+
93+
// Truncate long strings
94+
if (str.length > PAYLOAD_LIMITS.MAX_STRING_LENGTH) {
95+
return `[Truncated len=${str.length}]`
96+
}
97+
98+
return str
99+
}
100+
101+
/**
102+
* Recursively sanitize and redact secrets from an object.
103+
* Applies size limiting to prevent Output Channel from freezing:
104+
* - Strings longer than MAX_STRING_LENGTH are truncated
105+
* - Arrays longer than MAX_ARRAY_LENGTH are capped
106+
* - Objects with more than MAX_OBJECT_KEYS are capped
107+
* - Base64 image data URLs are replaced with placeholders
108+
* - Secret fields are redacted
109+
* Returns a sanitized copy of the object.
110+
*/
111+
function sanitizePayload(obj: unknown, visited = new WeakSet<object>()): unknown {
112+
if (obj === null || obj === undefined) {
113+
return obj
114+
}
115+
116+
// Handle strings
117+
if (typeof obj === "string") {
118+
return sanitizeString(obj)
119+
}
120+
121+
// Handle other primitives
122+
if (typeof obj !== "object") {
123+
return obj
124+
}
125+
126+
// Prevent infinite recursion on circular references
127+
if (visited.has(obj as object)) {
128+
return "[Circular Reference]"
129+
}
130+
visited.add(obj as object)
131+
132+
// Handle arrays with length limiting
133+
if (Array.isArray(obj)) {
134+
const maxLen = PAYLOAD_LIMITS.MAX_ARRAY_LENGTH
135+
if (obj.length > maxLen) {
136+
const truncated = obj.slice(0, maxLen).map((item) => sanitizePayload(item, visited))
137+
truncated.push(`[...${obj.length - maxLen} more items]`)
138+
return truncated
139+
}
140+
return obj.map((item) => sanitizePayload(item, visited))
141+
}
142+
143+
// Handle objects with key limiting
144+
const entries = Object.entries(obj as Record<string, unknown>)
145+
const maxKeys = PAYLOAD_LIMITS.MAX_OBJECT_KEYS
146+
const result: Record<string, unknown> = {}
147+
let keyCount = 0
148+
149+
for (const [key, value] of entries) {
150+
if (keyCount >= maxKeys) {
151+
result["[...]"] = `${entries.length - maxKeys} more keys omitted`
152+
break
153+
}
154+
155+
if (isSecretKey(key)) {
156+
result[key] = "[REDACTED]"
157+
} else if (typeof value === "string") {
158+
result[key] = sanitizeString(value)
159+
} else if (typeof value === "object" && value !== null) {
160+
result[key] = sanitizePayload(value, visited)
161+
} else {
162+
result[key] = value
163+
}
164+
165+
keyCount++
166+
}
167+
168+
return result
169+
}
170+
171+
/**
172+
* Generate a unique request ID.
173+
*/
174+
function generateRequestId(): string {
175+
return `req_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
176+
}
177+
178+
/**
179+
* Singleton logger class for API inference logging.
180+
*/
181+
class ApiInferenceLoggerSingleton {
182+
private enabled = false
183+
private sink: ((...args: unknown[]) => void) | null = null
184+
185+
/**
186+
* Configure the logger with enabled state and output sink.
187+
* Should be called once during extension activation.
188+
*/
189+
configure(config: ApiInferenceLoggerConfig): void {
190+
this.enabled = config.enabled
191+
this.sink = config.enabled ? config.sink : null
192+
}
193+
194+
/**
195+
* Check if logging is currently enabled.
196+
*/
197+
isEnabled(): boolean {
198+
return this.enabled && this.sink !== null
199+
}
200+
201+
/**
202+
* Start logging an API inference request.
203+
* Returns a handle to log the response or error.
204+
*
205+
* @param context - Context information about the request
206+
* @param requestPayload - The request payload to log
207+
* @returns A handle with success() and error() methods
208+
*/
209+
start(context: ApiInferenceContext, requestPayload: unknown): ApiInferenceHandle {
210+
const requestId = context.requestId ?? generateRequestId()
211+
const startTime = Date.now()
212+
const startTimestamp = new Date().toISOString()
213+
214+
// Log the request
215+
if (this.isEnabled()) {
216+
this.logRequest({
217+
...context,
218+
requestId,
219+
timestamp: startTimestamp,
220+
payload: requestPayload,
221+
})
222+
}
223+
224+
return {
225+
success: (responsePayload: unknown) => {
226+
if (this.isEnabled()) {
227+
const endTime = Date.now()
228+
this.logResponse({
229+
...context,
230+
requestId,
231+
timestamp: new Date().toISOString(),
232+
durationMs: endTime - startTime,
233+
payload: responsePayload,
234+
})
235+
}
236+
},
237+
error: (errorPayload: unknown) => {
238+
if (this.isEnabled()) {
239+
const endTime = Date.now()
240+
this.logError({
241+
...context,
242+
requestId,
243+
timestamp: new Date().toISOString(),
244+
durationMs: endTime - startTime,
245+
error: errorPayload,
246+
})
247+
}
248+
},
249+
}
250+
}
251+
252+
/**
253+
* Log a request with stable tag format.
254+
*/
255+
private logRequest(data: {
256+
provider: string
257+
operation: string
258+
model?: string
259+
taskId?: string
260+
requestId: string
261+
timestamp: string
262+
payload: unknown
263+
}): void {
264+
if (!this.sink) return
265+
266+
try {
267+
this.sink("[API][request]", {
268+
provider: data.provider,
269+
operation: data.operation,
270+
model: data.model,
271+
taskId: data.taskId,
272+
requestId: data.requestId,
273+
timestamp: data.timestamp,
274+
payload: sanitizePayload(data.payload),
275+
})
276+
} catch {
277+
// Silently ignore logging errors to avoid breaking the application
278+
}
279+
}
280+
281+
/**
282+
* Log a successful response with stable tag format.
283+
*/
284+
private logResponse(data: {
285+
provider: string
286+
operation: string
287+
model?: string
288+
taskId?: string
289+
requestId: string
290+
timestamp: string
291+
durationMs: number
292+
payload: unknown
293+
}): void {
294+
if (!this.sink) return
295+
296+
try {
297+
this.sink("[API][response]", {
298+
provider: data.provider,
299+
operation: data.operation,
300+
model: data.model,
301+
taskId: data.taskId,
302+
requestId: data.requestId,
303+
timestamp: data.timestamp,
304+
durationMs: data.durationMs,
305+
payload: sanitizePayload(data.payload),
306+
})
307+
} catch {
308+
// Silently ignore logging errors to avoid breaking the application
309+
}
310+
}
311+
312+
/**
313+
* Log an error response with stable tag format.
314+
*/
315+
private logError(data: {
316+
provider: string
317+
operation: string
318+
model?: string
319+
taskId?: string
320+
requestId: string
321+
timestamp: string
322+
durationMs: number
323+
error: unknown
324+
}): void {
325+
if (!this.sink) return
326+
327+
try {
328+
// Handle Error objects specially
329+
let errorData: unknown
330+
if (data.error instanceof Error) {
331+
errorData = {
332+
name: data.error.name,
333+
message: data.error.message,
334+
stack: data.error.stack,
335+
}
336+
} else {
337+
errorData = sanitizePayload(data.error)
338+
}
339+
340+
this.sink("[API][error]", {
341+
provider: data.provider,
342+
operation: data.operation,
343+
model: data.model,
344+
taskId: data.taskId,
345+
requestId: data.requestId,
346+
timestamp: data.timestamp,
347+
durationMs: data.durationMs,
348+
error: errorData,
349+
})
350+
} catch {
351+
// Silently ignore logging errors to avoid breaking the application
352+
}
353+
}
354+
}
355+
356+
/**
357+
* Singleton instance of the API inference logger.
358+
*/
359+
export const ApiInferenceLogger = new ApiInferenceLoggerSingleton()

0 commit comments

Comments
 (0)