Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 86 additions & 35 deletions packages/cli/src/utils/debug.mts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,20 @@ export type ApiRequestDebugInfo = {
url?: string | undefined
headers?: Record<string, string> | undefined
durationMs?: number | undefined
// ISO-8601 timestamp of when the request was initiated. Useful when
// correlating failures with server-side logs.
requestedAt?: string | undefined
// Response headers from the failed request. The helper extracts the
// cf-ray trace id as a first-class field so support can look it up in
// the Cloudflare dashboard without eyeballing the whole header dump.
responseHeaders?: Record<string, string> | undefined
// Response body string; truncated by the helper to a safe length so
// logs don't balloon on megabyte payloads.
responseBody?: string | undefined
}

const RESPONSE_BODY_TRUNCATE_LENGTH = 2_000

/**
* Sanitize headers to remove sensitive information.
* Redacts Authorization and API key headers.
Expand Down Expand Up @@ -76,16 +88,73 @@ export function debugApiRequest(
}
}

/**
* Build the structured debug payload shared by the error + failure-status
* branches of `debugApiResponse`. Extracted so both paths log the same
* shape.
*/
function buildApiDebugDetails(
base: Record<string, unknown>,
requestInfo?: ApiRequestDebugInfo | undefined,
): Record<string, unknown> {
if (!requestInfo) {
return base
}
const details: Record<string, unknown> = { ...base }
if (requestInfo.requestedAt) {
details['requestedAt'] = requestInfo.requestedAt
}
if (requestInfo.method) {
details['method'] = requestInfo.method
}
if (requestInfo.url) {
details['url'] = requestInfo.url
}
if (requestInfo.durationMs !== undefined) {
details['durationMs'] = requestInfo.durationMs
}
if (requestInfo.headers) {
details['headers'] = sanitizeHeaders(requestInfo.headers)
}
if (requestInfo.responseHeaders) {
const cfRay =
requestInfo.responseHeaders['cf-ray'] ??
requestInfo.responseHeaders['CF-Ray']
if (cfRay) {
// First-class field so it's obvious when filing a support ticket
// that points at a Cloudflare trace.
details['cfRay'] = cfRay
}
details['responseHeaders'] = sanitizeHeaders(requestInfo.responseHeaders)
}
if (requestInfo.responseBody !== undefined) {
const body = requestInfo.responseBody
// `.length` / `.slice` operate on UTF-16 code units, not bytes, so
// the counter and truncation are both reported in "chars" to stay
// consistent with what we actually measured.
details['responseBody'] =
body.length > RESPONSE_BODY_TRUNCATE_LENGTH
? `${body.slice(0, RESPONSE_BODY_TRUNCATE_LENGTH)}… (truncated, ${body.length} chars)`
: body
}
return details
}

/**
* Debug an API response with detailed request information.
* Logs essential info without exposing sensitive data.
*
* For failed requests (status >= 400 or error), logs:
* - HTTP method (GET, POST, etc.)
* - Full URL
* - Response status code
* - Sanitized headers (Authorization redacted)
* - Request duration in milliseconds
* For failed requests (status >= 400 or error), logs a structured
* object with:
* - endpoint (human-readable description)
* - requestedAt (ISO timestamp, if passed)
* - method, url, durationMs
* - sanitized request headers (Authorization redacted)
* - cfRay (extracted from response headers if present)
* - sanitized response headers
* - responseBody (truncated)
*
* All request-headers are sanitized to redact Authorization and
* `*api-key*` values.
*/
export function debugApiResponse(
endpoint: string,
Expand All @@ -94,37 +163,19 @@ export function debugApiResponse(
requestInfo?: ApiRequestDebugInfo | undefined,
): void {
if (error) {
const errorDetails = {
__proto__: null,
endpoint,
error: error instanceof Error ? error.message : UNKNOWN_ERROR,
...(requestInfo?.method ? { method: requestInfo.method } : {}),
...(requestInfo?.url ? { url: requestInfo.url } : {}),
...(requestInfo?.durationMs !== undefined
? { durationMs: requestInfo.durationMs }
: {}),
...(requestInfo?.headers
? { headers: sanitizeHeaders(requestInfo.headers) }
: {}),
}
debugDir(errorDetails)
debugDir(
buildApiDebugDetails(
{
endpoint,
error: error instanceof Error ? error.message : UNKNOWN_ERROR,
},
requestInfo,
),
)
} else if (status && status >= 400) {
// For failed requests, log detailed information.
if (requestInfo) {
const failureDetails = {
__proto__: null,
endpoint,
status,
...(requestInfo.method ? { method: requestInfo.method } : {}),
...(requestInfo.url ? { url: requestInfo.url } : {}),
...(requestInfo.durationMs !== undefined
? { durationMs: requestInfo.durationMs }
: {}),
...(requestInfo.headers
? { headers: sanitizeHeaders(requestInfo.headers) }
: {}),
}
debugDir(failureDetails)
debugDir(buildApiDebugDetails({ endpoint, status }, requestInfo))
} else {
debug(`API ${endpoint}: HTTP ${status}`)
}
Expand Down
32 changes: 30 additions & 2 deletions packages/cli/src/utils/socket/api.mts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ export async function socketHttpRequest(
return await httpRequest(url, options)
}

// Safe wrapper for `response.text()` in error-handling code paths.
// `text()` can throw (e.g. already consumed, malformed body), which
// would blow past the `ok: false` CResult return and break the
// error-handling contract of callers like `queryApiSafeText`.
function tryReadResponseText(result: HttpResponse): string | undefined {
try {
return result.text?.()
} catch {
return undefined
}
}

export type CommandRequirements = {
permissions?: string[] | undefined
quota?: number | undefined
Expand Down Expand Up @@ -425,6 +437,7 @@ export async function queryApiSafeText(
const baseUrl = getDefaultApiBaseUrl()
const fullUrl = `${baseUrl}${baseUrl?.endsWith('/') ? '' : '/'}${path}`
const startTime = Date.now()
const requestedAt = new Date(startTime).toISOString()

let result: any
try {
Expand All @@ -440,6 +453,7 @@ export async function queryApiSafeText(
method: 'GET',
url: fullUrl,
durationMs,
requestedAt,
headers: { Authorization: '[REDACTED]' },
})
} catch (e) {
Expand All @@ -455,6 +469,7 @@ export async function queryApiSafeText(
method: 'GET',
url: fullUrl,
durationMs,
requestedAt,
headers: { Authorization: '[REDACTED]' },
})

Expand All @@ -472,12 +487,17 @@ export async function queryApiSafeText(
if (!result.ok) {
const { status } = result
const durationMs = Date.now() - startTime
// Log detailed error information.
// Log detailed error information — include response headers (for
// cf-ray) and a truncated body so support tickets have everything
// needed to file against Cloudflare or backend teams.
debugApiResponse(description || 'Query API', status, undefined, {
method: 'GET',
url: fullUrl,
durationMs,
requestedAt,
headers: { Authorization: '[REDACTED]' },
responseHeaders: result.headers,
responseBody: tryReadResponseText(result),
})
// Log required permissions for 403 errors when in a command context.
if (commandPath && status === 403) {
Expand Down Expand Up @@ -584,6 +604,7 @@ export async function sendApiRequest<T>(

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

let result: any
try {
Expand Down Expand Up @@ -611,6 +632,7 @@ export async function sendApiRequest<T>(
method,
url: fullUrl,
durationMs,
requestedAt,
headers: {
Authorization: '[REDACTED]',
'Content-Type': 'application/json',
Expand All @@ -630,6 +652,7 @@ export async function sendApiRequest<T>(
method,
url: fullUrl,
durationMs,
requestedAt,
headers: {
Authorization: '[REDACTED]',
'Content-Type': 'application/json',
Expand All @@ -650,15 +673,20 @@ export async function sendApiRequest<T>(
if (!result.ok) {
const { status } = result
const durationMs = Date.now() - startTime
// Log detailed error information.
// Log detailed error information — include response headers (for
// cf-ray) and a truncated body so support tickets have everything
// needed to file against Cloudflare or backend teams.
debugApiResponse(description || 'Send API Request', status, undefined, {
method,
url: fullUrl,
durationMs,
requestedAt,
headers: {
Authorization: '[REDACTED]',
'Content-Type': 'application/json',
},
responseHeaders: result.headers,
responseBody: tryReadResponseText(result),
})
// Log required permissions for 403 errors when in a command context.
if (commandPath && status === 403) {
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/test/unit/utils/debug.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,81 @@ describe('debug utilities', () => {
expect(calledWith.url).toBeUndefined()
expect(calledWith.headers).toBeUndefined()
})

it('includes requestedAt timestamp when provided', () => {
const requestInfo = {
method: 'POST',
url: 'https://api.socket.dev/x',
requestedAt: '2026-04-18T00:00:00.000Z',
}

debugApiResponse('/api/x', 500, undefined, requestInfo)

const calledWith = mockDebugDir.mock.calls[0]?.[0]
expect(calledWith.requestedAt).toBe('2026-04-18T00:00:00.000Z')
})

it('extracts cf-ray as a top-level field and keeps responseHeaders', () => {
const requestInfo = {
method: 'GET',
url: 'https://api.socket.dev/y',
responseHeaders: {
'cf-ray': 'abc123-IAD',
'content-type': 'application/json',
},
}

debugApiResponse('/api/y', 500, undefined, requestInfo)

const calledWith = mockDebugDir.mock.calls[0]?.[0]
expect(calledWith.cfRay).toBe('abc123-IAD')
expect(calledWith.responseHeaders?.['cf-ray']).toBe('abc123-IAD')
})

it('tolerates CF-Ray header casing', () => {
const requestInfo = {
method: 'GET',
url: 'https://api.socket.dev/z',
responseHeaders: {
'CF-Ray': 'xyz789-SJC',
},
}

debugApiResponse('/api/z', 500, undefined, requestInfo)

const calledWith = mockDebugDir.mock.calls[0]?.[0]
expect(calledWith.cfRay).toBe('xyz789-SJC')
})

it('includes response body on error', () => {
const requestInfo = {
method: 'GET',
url: 'https://api.socket.dev/body',
responseBody: '{"error":"bad"}',
}

debugApiResponse('/api/body', 400, undefined, requestInfo)

const calledWith = mockDebugDir.mock.calls[0]?.[0]
expect(calledWith.responseBody).toBe('{"error":"bad"}')
})

it('truncates oversized response bodies', () => {
const bigBody = 'x'.repeat(5000)
const requestInfo = {
method: 'GET',
url: 'https://api.socket.dev/big',
responseBody: bigBody,
}

debugApiResponse('/api/big', 500, undefined, requestInfo)

const calledWith = mockDebugDir.mock.calls[0]?.[0]
expect(calledWith.responseBody).toMatch(/… \(truncated, 5000 chars\)$/)
expect((calledWith.responseBody as string).length).toBeLessThan(
bigBody.length,
)
})
})

describe('debugFileOp', () => {
Expand Down