Skip to content

Commit a6ee497

Browse files
committed
fix(debug): log structured HTTP error details instead of raw response
When an HTTP request fails, `debugApiResponse` now logs: * endpoint (description) — already was * status — already was * method, url, durationMs — already was * sanitized request headers (Authorization/api-keys redacted) — already was And new fields that make support tickets actionable: * requestedAt — ISO-8601 timestamp of request start, for correlating with server-side logs. * cfRay — Cloudflare trace id extracted as a top-level field from response headers (accepts `cf-ray` or `CF-Ray` casing). * responseHeaders — sanitized headers returned by the server. * responseBody — string response body, truncated at 2_000 bytes so megabyte payloads don't balloon debug logs. Wired into both the `queryApiSafeText` and `sendApiRequest` !ok branches, which are the primary points where a non-thrown HTTP error reaches a user. The success-path log includes the new `requestedAt` timestamp too. Added 5 new tests in `test/unit/utils/debug.test.mts` covering requestedAt, cfRay (both casings), body passthrough, and body truncation. Existing debug-output shape preserved: callers that don't pass the new fields (`responseHeaders`, `responseBody`, `requestedAt`) see no change.
1 parent 64a14c5 commit a6ee497

File tree

3 files changed

+176
-37
lines changed

3 files changed

+176
-37
lines changed

packages/cli/src/utils/debug.mts

Lines changed: 83 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,20 @@ export type ApiRequestDebugInfo = {
3333
url?: string | undefined
3434
headers?: Record<string, string> | undefined
3535
durationMs?: number | undefined
36+
// ISO-8601 timestamp of when the request was initiated. Useful when
37+
// correlating failures with server-side logs.
38+
requestedAt?: string | undefined
39+
// Response headers from the failed request. The helper extracts the
40+
// cf-ray trace id as a first-class field so support can look it up in
41+
// the Cloudflare dashboard without eyeballing the whole header dump.
42+
responseHeaders?: Record<string, string> | undefined
43+
// Response body string; truncated by the helper to a safe length so
44+
// logs don't balloon on megabyte payloads.
45+
responseBody?: string | undefined
3646
}
3747

48+
const RESPONSE_BODY_TRUNCATE_LENGTH = 2_000
49+
3850
/**
3951
* Sanitize headers to remove sensitive information.
4052
* Redacts Authorization and API key headers.
@@ -76,16 +88,70 @@ export function debugApiRequest(
7688
}
7789
}
7890

91+
/**
92+
* Build the structured debug payload shared by the error + failure-status
93+
* branches of `debugApiResponse`. Extracted so both paths log the same
94+
* shape.
95+
*/
96+
function buildApiDebugDetails(
97+
base: Record<string, unknown>,
98+
requestInfo?: ApiRequestDebugInfo | undefined,
99+
): Record<string, unknown> {
100+
if (!requestInfo) {
101+
return base
102+
}
103+
const details: Record<string, unknown> = { ...base }
104+
if (requestInfo.requestedAt) {
105+
details['requestedAt'] = requestInfo.requestedAt
106+
}
107+
if (requestInfo.method) {
108+
details['method'] = requestInfo.method
109+
}
110+
if (requestInfo.url) {
111+
details['url'] = requestInfo.url
112+
}
113+
if (requestInfo.durationMs !== undefined) {
114+
details['durationMs'] = requestInfo.durationMs
115+
}
116+
if (requestInfo.headers) {
117+
details['headers'] = sanitizeHeaders(requestInfo.headers)
118+
}
119+
if (requestInfo.responseHeaders) {
120+
const cfRay =
121+
requestInfo.responseHeaders['cf-ray'] ??
122+
requestInfo.responseHeaders['CF-Ray']
123+
if (cfRay) {
124+
// First-class field so it's obvious when filing a support ticket
125+
// that points at a Cloudflare trace.
126+
details['cfRay'] = cfRay
127+
}
128+
details['responseHeaders'] = sanitizeHeaders(requestInfo.responseHeaders)
129+
}
130+
if (requestInfo.responseBody !== undefined) {
131+
const body = requestInfo.responseBody
132+
details['responseBody'] =
133+
body.length > RESPONSE_BODY_TRUNCATE_LENGTH
134+
? `${body.slice(0, RESPONSE_BODY_TRUNCATE_LENGTH)}… (truncated, ${body.length} bytes)`
135+
: body
136+
}
137+
return details
138+
}
139+
79140
/**
80141
* Debug an API response with detailed request information.
81-
* Logs essential info without exposing sensitive data.
82142
*
83-
* For failed requests (status >= 400 or error), logs:
84-
* - HTTP method (GET, POST, etc.)
85-
* - Full URL
86-
* - Response status code
87-
* - Sanitized headers (Authorization redacted)
88-
* - Request duration in milliseconds
143+
* For failed requests (status >= 400 or error), logs a structured
144+
* object with:
145+
* - endpoint (human-readable description)
146+
* - requestedAt (ISO timestamp, if passed)
147+
* - method, url, durationMs
148+
* - sanitized request headers (Authorization redacted)
149+
* - cfRay (extracted from response headers if present)
150+
* - sanitized response headers
151+
* - responseBody (truncated)
152+
*
153+
* All request-headers are sanitized to redact Authorization and
154+
* `*api-key*` values.
89155
*/
90156
export function debugApiResponse(
91157
endpoint: string,
@@ -94,37 +160,19 @@ export function debugApiResponse(
94160
requestInfo?: ApiRequestDebugInfo | undefined,
95161
): void {
96162
if (error) {
97-
const errorDetails = {
98-
__proto__: null,
99-
endpoint,
100-
error: error instanceof Error ? error.message : UNKNOWN_ERROR,
101-
...(requestInfo?.method ? { method: requestInfo.method } : {}),
102-
...(requestInfo?.url ? { url: requestInfo.url } : {}),
103-
...(requestInfo?.durationMs !== undefined
104-
? { durationMs: requestInfo.durationMs }
105-
: {}),
106-
...(requestInfo?.headers
107-
? { headers: sanitizeHeaders(requestInfo.headers) }
108-
: {}),
109-
}
110-
debugDir(errorDetails)
163+
debugDir(
164+
buildApiDebugDetails(
165+
{
166+
endpoint,
167+
error: error instanceof Error ? error.message : UNKNOWN_ERROR,
168+
},
169+
requestInfo,
170+
),
171+
)
111172
} else if (status && status >= 400) {
112173
// For failed requests, log detailed information.
113174
if (requestInfo) {
114-
const failureDetails = {
115-
__proto__: null,
116-
endpoint,
117-
status,
118-
...(requestInfo.method ? { method: requestInfo.method } : {}),
119-
...(requestInfo.url ? { url: requestInfo.url } : {}),
120-
...(requestInfo.durationMs !== undefined
121-
? { durationMs: requestInfo.durationMs }
122-
: {}),
123-
...(requestInfo.headers
124-
? { headers: sanitizeHeaders(requestInfo.headers) }
125-
: {}),
126-
}
127-
debugDir(failureDetails)
175+
debugDir(buildApiDebugDetails({ endpoint, status }, requestInfo))
128176
} else {
129177
debug(`API ${endpoint}: HTTP ${status}`)
130178
}

packages/cli/src/utils/socket/api.mts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ export async function queryApiSafeText(
425425
const baseUrl = getDefaultApiBaseUrl()
426426
const fullUrl = `${baseUrl}${baseUrl?.endsWith('/') ? '' : '/'}${path}`
427427
const startTime = Date.now()
428+
const requestedAt = new Date(startTime).toISOString()
428429

429430
let result: any
430431
try {
@@ -440,6 +441,7 @@ export async function queryApiSafeText(
440441
method: 'GET',
441442
url: fullUrl,
442443
durationMs,
444+
requestedAt,
443445
headers: { Authorization: '[REDACTED]' },
444446
})
445447
} catch (e) {
@@ -455,6 +457,7 @@ export async function queryApiSafeText(
455457
method: 'GET',
456458
url: fullUrl,
457459
durationMs,
460+
requestedAt,
458461
headers: { Authorization: '[REDACTED]' },
459462
})
460463

@@ -472,12 +475,17 @@ export async function queryApiSafeText(
472475
if (!result.ok) {
473476
const { status } = result
474477
const durationMs = Date.now() - startTime
475-
// Log detailed error information.
478+
// Log detailed error information — include response headers (for
479+
// cf-ray) and a truncated body so support tickets have everything
480+
// needed to file against Cloudflare or backend teams.
476481
debugApiResponse(description || 'Query API', status, undefined, {
477482
method: 'GET',
478483
url: fullUrl,
479484
durationMs,
485+
requestedAt,
480486
headers: { Authorization: '[REDACTED]' },
487+
responseHeaders: result.headers,
488+
responseBody: result.text?.(),
481489
})
482490
// Log required permissions for 403 errors when in a command context.
483491
if (commandPath && status === 403) {
@@ -584,6 +592,7 @@ export async function sendApiRequest<T>(
584592

585593
const fullUrl = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`
586594
const startTime = Date.now()
595+
const requestedAt = new Date(startTime).toISOString()
587596

588597
let result: any
589598
try {
@@ -611,6 +620,7 @@ export async function sendApiRequest<T>(
611620
method,
612621
url: fullUrl,
613622
durationMs,
623+
requestedAt,
614624
headers: {
615625
Authorization: '[REDACTED]',
616626
'Content-Type': 'application/json',
@@ -630,6 +640,7 @@ export async function sendApiRequest<T>(
630640
method,
631641
url: fullUrl,
632642
durationMs,
643+
requestedAt,
633644
headers: {
634645
Authorization: '[REDACTED]',
635646
'Content-Type': 'application/json',
@@ -650,15 +661,20 @@ export async function sendApiRequest<T>(
650661
if (!result.ok) {
651662
const { status } = result
652663
const durationMs = Date.now() - startTime
653-
// Log detailed error information.
664+
// Log detailed error information — include response headers (for
665+
// cf-ray) and a truncated body so support tickets have everything
666+
// needed to file against Cloudflare or backend teams.
654667
debugApiResponse(description || 'Send API Request', status, undefined, {
655668
method,
656669
url: fullUrl,
657670
durationMs,
671+
requestedAt,
658672
headers: {
659673
Authorization: '[REDACTED]',
660674
'Content-Type': 'application/json',
661675
},
676+
responseHeaders: result.headers,
677+
responseBody: result.text?.(),
662678
})
663679
// Log required permissions for 403 errors when in a command context.
664680
if (commandPath && status === 403) {

packages/cli/test/unit/utils/debug.test.mts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,81 @@ describe('debug utilities', () => {
188188
expect(calledWith.url).toBeUndefined()
189189
expect(calledWith.headers).toBeUndefined()
190190
})
191+
192+
it('includes requestedAt timestamp when provided', () => {
193+
const requestInfo = {
194+
method: 'POST',
195+
url: 'https://api.socket.dev/x',
196+
requestedAt: '2026-04-18T00:00:00.000Z',
197+
}
198+
199+
debugApiResponse('/api/x', 500, undefined, requestInfo)
200+
201+
const calledWith = mockDebugDir.mock.calls[0]?.[0]
202+
expect(calledWith.requestedAt).toBe('2026-04-18T00:00:00.000Z')
203+
})
204+
205+
it('extracts cf-ray as a top-level field and keeps responseHeaders', () => {
206+
const requestInfo = {
207+
method: 'GET',
208+
url: 'https://api.socket.dev/y',
209+
responseHeaders: {
210+
'cf-ray': 'abc123-IAD',
211+
'content-type': 'application/json',
212+
},
213+
}
214+
215+
debugApiResponse('/api/y', 500, undefined, requestInfo)
216+
217+
const calledWith = mockDebugDir.mock.calls[0]?.[0]
218+
expect(calledWith.cfRay).toBe('abc123-IAD')
219+
expect(calledWith.responseHeaders?.['cf-ray']).toBe('abc123-IAD')
220+
})
221+
222+
it('tolerates CF-Ray header casing', () => {
223+
const requestInfo = {
224+
method: 'GET',
225+
url: 'https://api.socket.dev/z',
226+
responseHeaders: {
227+
'CF-Ray': 'xyz789-SJC',
228+
},
229+
}
230+
231+
debugApiResponse('/api/z', 500, undefined, requestInfo)
232+
233+
const calledWith = mockDebugDir.mock.calls[0]?.[0]
234+
expect(calledWith.cfRay).toBe('xyz789-SJC')
235+
})
236+
237+
it('includes response body on error', () => {
238+
const requestInfo = {
239+
method: 'GET',
240+
url: 'https://api.socket.dev/body',
241+
responseBody: '{"error":"bad"}',
242+
}
243+
244+
debugApiResponse('/api/body', 400, undefined, requestInfo)
245+
246+
const calledWith = mockDebugDir.mock.calls[0]?.[0]
247+
expect(calledWith.responseBody).toBe('{"error":"bad"}')
248+
})
249+
250+
it('truncates oversized response bodies', () => {
251+
const bigBody = 'x'.repeat(5000)
252+
const requestInfo = {
253+
method: 'GET',
254+
url: 'https://api.socket.dev/big',
255+
responseBody: bigBody,
256+
}
257+
258+
debugApiResponse('/api/big', 500, undefined, requestInfo)
259+
260+
const calledWith = mockDebugDir.mock.calls[0]?.[0]
261+
expect(calledWith.responseBody).toMatch(/ \(truncated, 5000 bytes\)$/)
262+
expect((calledWith.responseBody as string).length).toBeLessThan(
263+
bigBody.length,
264+
)
265+
})
191266
})
192267

193268
describe('debugFileOp', () => {

0 commit comments

Comments
 (0)