Skip to content

Commit 35b9b9c

Browse files
authored
fix(debug): log structured HTTP error details instead of raw response (#1233)
* 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. * fix(api): guard response.text() in error paths and label chars Addresses Cursor bugbot feedback on PR #1233. 1. `result.text?.()` in the `!result.ok` branches of `queryApiSafeText` and `sendApiRequest` was unguarded. If `text()` threw, the exception propagated past the clean `{ ok: false, ... }` return and broke the error-handling contract. Wrap both call sites in a shared `tryReadResponseText` helper that swallows the failure and returns `undefined`. 2. The truncation suffix reported `body.length` as "bytes", but `String.prototype.length` / `String.prototype.slice` count UTF-16 code units, not bytes. For non-ASCII response bodies the label was misleading. Rename to "chars" so the counter matches the actual measurement. * chore(debug): trim redundant comments in API debug logging * chore(debug): restore __proto__: null on API debug payload * chore(debug): sort ApiRequestDebugInfo properties alphabetically * refactor(debug): route API failure logs through the error namespace * fix(ci): bump pnpm to 11.0.0-rc.3 to unblock CI CI was failing with `pnpm: 1: This: not found` (exit 127) during the install step because @pnpm/exe@11.0.0-rc.2 ships a broken shell shim — its pnpm binary begins with "This..." which the shell tries to execute as a command. Affects main too, not just this PR. - Bump `packageManager` from `pnpm@11.0.0-rc.2` to `pnpm@11.0.0-rc.3`. - Bump `engines.pnpm` to `>=11.0.0-rc.3` to match. - Regenerate `pnpm-lock.yaml` with rc.3 — the new lockfile no longer pulls @pnpm/exe into packageManagerDependencies, which is why the footprint shrinks. @pnpm/exe stays in the allowBuilds allowlist in `pnpm-workspace.yaml` in case it's ever resolved transitively. The socket-registry setup-and-install action (SHA a5923566c) already installs pnpm rc.3, matching the upstream `_local-not-for-reuse-*` workflows — so action pins do not need to change.
1 parent de9daaf commit 35b9b9c

5 files changed

Lines changed: 235 additions & 261 deletions

File tree

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
22
"name": "socket-cli-monorepo",
33
"version": "0.0.0",
4-
"packageManager": "pnpm@11.0.0-rc.2",
4+
"packageManager": "pnpm@11.0.0-rc.3",
55
"private": true,
66
"engines": {
77
"node": ">=25.9.0",
8-
"pnpm": ">=11.0.0-rc.2"
8+
"pnpm": ">=11.0.0-rc.3"
99
},
1010
"scripts": {
1111
"// Build": "",

packages/cli/src/utils/debug.mts

Lines changed: 85 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,24 @@ import {
2929
} from '@socketsecurity/lib/debug'
3030

3131
export type ApiRequestDebugInfo = {
32+
durationMs?: number | undefined
33+
headers?: Record<string, string> | undefined
3234
method?: string | undefined
35+
// ISO-8601 timestamp of when the request was initiated. Useful when
36+
// correlating failures with server-side logs.
37+
requestedAt?: string | undefined
38+
// Response body string; truncated by the helper to a safe length so
39+
// logs don't balloon on megabyte payloads.
40+
responseBody?: string | undefined
41+
// Response headers from the failed request. The helper extracts the
42+
// cf-ray trace id as a first-class field so support can look it up in
43+
// the Cloudflare dashboard without eyeballing the whole header dump.
44+
responseHeaders?: Record<string, string> | undefined
3345
url?: string | undefined
34-
headers?: Record<string, string> | undefined
35-
durationMs?: number | 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.
@@ -77,15 +89,66 @@ export function debugApiRequest(
7789
}
7890

7991
/**
80-
* Debug an API response with detailed request information.
81-
* Logs essential info without exposing sensitive data.
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+
// `__proto__: null` keeps the payload free of prototype-chain keys
101+
// when callers iterate over the debug output.
102+
const details: Record<string, unknown> = { __proto__: null, ...base } as Record<string, unknown>
103+
if (!requestInfo) {
104+
return details
105+
}
106+
if (requestInfo.requestedAt) {
107+
details['requestedAt'] = requestInfo.requestedAt
108+
}
109+
if (requestInfo.method) {
110+
details['method'] = requestInfo.method
111+
}
112+
if (requestInfo.url) {
113+
details['url'] = requestInfo.url
114+
}
115+
if (requestInfo.durationMs !== undefined) {
116+
details['durationMs'] = requestInfo.durationMs
117+
}
118+
if (requestInfo.headers) {
119+
details['headers'] = sanitizeHeaders(requestInfo.headers)
120+
}
121+
if (requestInfo.responseHeaders) {
122+
const cfRay =
123+
requestInfo.responseHeaders['cf-ray'] ??
124+
requestInfo.responseHeaders['CF-Ray']
125+
if (cfRay) {
126+
// First-class field so it's obvious when filing a support ticket
127+
// that points at a Cloudflare trace.
128+
details['cfRay'] = cfRay
129+
}
130+
details['responseHeaders'] = sanitizeHeaders(requestInfo.responseHeaders)
131+
}
132+
if (requestInfo.responseBody !== undefined) {
133+
const body = requestInfo.responseBody
134+
// `.length` / `.slice` operate on UTF-16 code units, not bytes, so
135+
// the counter and truncation are both reported in "chars" to stay
136+
// consistent with what we actually measured.
137+
details['responseBody'] =
138+
body.length > RESPONSE_BODY_TRUNCATE_LENGTH
139+
? `${body.slice(0, RESPONSE_BODY_TRUNCATE_LENGTH)}… (truncated, ${body.length} chars)`
140+
: body
141+
}
142+
return details
143+
}
144+
145+
/**
146+
* Debug an API response. Failed requests (error or status >= 400) log
147+
* under the `error` namespace; successful responses optionally log a
148+
* one-liner under `notice`.
82149
*
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
150+
* Request and response headers are sanitized via `sanitizeHeaders` so
151+
* Authorization and `*api-key*` values are redacted.
89152
*/
90153
export function debugApiResponse(
91154
endpoint: string,
@@ -94,39 +157,21 @@ export function debugApiResponse(
94157
requestInfo?: ApiRequestDebugInfo | undefined,
95158
): void {
96159
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)
160+
debugDirNs(
161+
'error',
162+
buildApiDebugDetails(
163+
{
164+
endpoint,
165+
error: error instanceof Error ? error.message : UNKNOWN_ERROR,
166+
},
167+
requestInfo,
168+
),
169+
)
111170
} else if (status && status >= 400) {
112-
// For failed requests, log detailed information.
113171
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)
172+
debugDirNs('error', buildApiDebugDetails({ endpoint, status }, requestInfo))
128173
} else {
129-
debug(`API ${endpoint}: HTTP ${status}`)
174+
debugNs('error', `API ${endpoint}: HTTP ${status}`)
130175
}
131176
/* c8 ignore next 3 */
132177
} else if (isDebugNs('notice')) {

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ export async function socketHttpRequest(
8282
return await httpRequest(url, options)
8383
}
8484

85+
// Safe wrapper for `response.text()` in error-handling code paths.
86+
// `text()` can throw (e.g. already consumed, malformed body), which
87+
// would blow past the `ok: false` CResult return and break the
88+
// error-handling contract of callers like `queryApiSafeText`.
89+
function tryReadResponseText(result: HttpResponse): string | undefined {
90+
try {
91+
return result.text?.()
92+
} catch {
93+
return undefined
94+
}
95+
}
96+
8597
export type CommandRequirements = {
8698
permissions?: string[] | undefined
8799
quota?: number | undefined
@@ -428,6 +440,7 @@ export async function queryApiSafeText(
428440
const baseUrl = getDefaultApiBaseUrl()
429441
const fullUrl = `${baseUrl}${baseUrl?.endsWith('/') ? '' : '/'}${path}`
430442
const startTime = Date.now()
443+
const requestedAt = new Date(startTime).toISOString()
431444

432445
let result: any
433446
try {
@@ -443,6 +456,7 @@ export async function queryApiSafeText(
443456
method: 'GET',
444457
url: fullUrl,
445458
durationMs,
459+
requestedAt,
446460
headers: { Authorization: '[REDACTED]' },
447461
})
448462
} catch (e) {
@@ -458,6 +472,7 @@ export async function queryApiSafeText(
458472
method: 'GET',
459473
url: fullUrl,
460474
durationMs,
475+
requestedAt,
461476
headers: { Authorization: '[REDACTED]' },
462477
})
463478

@@ -475,12 +490,17 @@ export async function queryApiSafeText(
475490
if (!result.ok) {
476491
const { status } = result
477492
const durationMs = Date.now() - startTime
478-
// Log detailed error information.
493+
// Include response headers (for cf-ray) and a truncated body so
494+
// support tickets have everything needed to file against Cloudflare
495+
// or backend teams.
479496
debugApiResponse(description || 'Query API', status, undefined, {
480497
method: 'GET',
481498
url: fullUrl,
482499
durationMs,
500+
requestedAt,
483501
headers: { Authorization: '[REDACTED]' },
502+
responseHeaders: result.headers,
503+
responseBody: tryReadResponseText(result),
484504
})
485505
// Log required permissions for 403 errors when in a command context.
486506
if (commandPath && status === 403) {
@@ -587,6 +607,7 @@ export async function sendApiRequest<T>(
587607

588608
const fullUrl = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`
589609
const startTime = Date.now()
610+
const requestedAt = new Date(startTime).toISOString()
590611

591612
let result: any
592613
try {
@@ -614,6 +635,7 @@ export async function sendApiRequest<T>(
614635
method,
615636
url: fullUrl,
616637
durationMs,
638+
requestedAt,
617639
headers: {
618640
Authorization: '[REDACTED]',
619641
'Content-Type': 'application/json',
@@ -633,6 +655,7 @@ export async function sendApiRequest<T>(
633655
method,
634656
url: fullUrl,
635657
durationMs,
658+
requestedAt,
636659
headers: {
637660
Authorization: '[REDACTED]',
638661
'Content-Type': 'application/json',
@@ -653,15 +676,20 @@ export async function sendApiRequest<T>(
653676
if (!result.ok) {
654677
const { status } = result
655678
const durationMs = Date.now() - startTime
656-
// Log detailed error information.
679+
// Include response headers (for cf-ray) and a truncated body so
680+
// support tickets have everything needed to file against Cloudflare
681+
// or backend teams.
657682
debugApiResponse(description || 'Send API Request', status, undefined, {
658683
method,
659684
url: fullUrl,
660685
durationMs,
686+
requestedAt,
661687
headers: {
662688
Authorization: '[REDACTED]',
663689
'Content-Type': 'application/json',
664690
},
691+
responseHeaders: result.headers,
692+
responseBody: tryReadResponseText(result),
665693
})
666694
// Log required permissions for 403 errors when in a command context.
667695
if (commandPath && status === 403) {

0 commit comments

Comments
 (0)