Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
125 changes: 85 additions & 40 deletions packages/cli/src/utils/debug.mts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,24 @@ import {
} from '@socketsecurity/lib/debug'

export type ApiRequestDebugInfo = {
durationMs?: number | undefined
headers?: Record<string, string> | undefined
method?: string | undefined
// ISO-8601 timestamp of when the request was initiated. Useful when
// correlating failures with server-side logs.
requestedAt?: string | undefined
// Response body string; truncated by the helper to a safe length so
// logs don't balloon on megabyte payloads.
responseBody?: 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
url?: string | undefined
headers?: Record<string, string> | undefined
durationMs?: number | undefined
}

const RESPONSE_BODY_TRUNCATE_LENGTH = 2_000

/**
* Sanitize headers to remove sensitive information.
* Redacts Authorization and API key headers.
Expand Down Expand Up @@ -77,15 +89,66 @@ export function debugApiRequest(
}

/**
* Debug an API response with detailed request information.
* Logs essential info without exposing sensitive data.
* 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> {
// `__proto__: null` keeps the payload free of prototype-chain keys
// when callers iterate over the debug output.
const details: Record<string, unknown> = { __proto__: null, ...base } as Record<string, unknown>
if (!requestInfo) {
return details
}
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. Failed requests (error or status >= 400) log
* under the `error` namespace; successful responses optionally log a
* one-liner under `notice`.
*
* 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
* Request and response headers are sanitized via `sanitizeHeaders` so
* Authorization and `*api-key*` values are redacted.
*/
export function debugApiResponse(
endpoint: string,
Expand All @@ -94,39 +157,21 @@ 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)
debugDirNs(
'error',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious if a specific namespace should be used, rather than just error?

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)
debugDirNs('error', buildApiDebugDetails({ endpoint, status }, requestInfo))
} else {
debug(`API ${endpoint}: HTTP ${status}`)
debugNs('error', `API ${endpoint}: HTTP ${status}`)
}
/* c8 ignore next 3 */
} else if (isDebugNs('notice')) {
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.
// 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.
// 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
89 changes: 82 additions & 7 deletions packages/cli/test/unit/utils/debug.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,16 @@ describe('debug utilities', () => {

debugApiResponse('/api/test', undefined, error)

expect(debugDir).toHaveBeenCalledWith({
expect(mockDebugDirNs).toHaveBeenCalledWith('error', {
endpoint: '/api/test',
error: 'API failed',
})
})

it('logs warning for HTTP error status codes', () => {
it('logs under error namespace for HTTP error status codes', () => {
debugApiResponse('/api/test', 404)

expect(debug).toHaveBeenCalledWith('API /api/test: HTTP 404')
expect(debugNs).toHaveBeenCalledWith('error', 'API /api/test: HTTP 404')
})

it('logs notice for successful responses when debug is enabled', () => {
Expand All @@ -128,7 +128,7 @@ describe('debug utilities', () => {
it('handles non-Error objects in error parameter', () => {
debugApiResponse('/api/test', undefined, 'String error')

expect(debugDir).toHaveBeenCalledWith({
expect(mockDebugDirNs).toHaveBeenCalledWith('error', {
endpoint: '/api/test',
error: 'Unknown error',
})
Expand All @@ -148,7 +148,7 @@ describe('debug utilities', () => {

debugApiResponse('/api/test', undefined, error, requestInfo)

const calledWith = mockDebugDir.mock.calls[0]?.[0]
const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as any
expect(calledWith.method).toBe('POST')
expect(calledWith.url).toBe('https://api.socket.dev/test')
expect(calledWith.durationMs).toBe(1500)
Expand All @@ -169,7 +169,7 @@ describe('debug utilities', () => {

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

const calledWith = mockDebugDir.mock.calls[0]?.[0]
const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as any
expect(calledWith.status).toBe(500)
expect(calledWith.method).toBe('GET')
// API key should be redacted.
Expand All @@ -183,11 +183,86 @@ describe('debug utilities', () => {

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

const calledWith = mockDebugDir.mock.calls[0]?.[0]
const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as any
expect(calledWith.method).toBe('PUT')
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 = mockDebugDirNs.mock.calls[0]?.[1] as any
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 = mockDebugDirNs.mock.calls[0]?.[1] as any
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 = mockDebugDirNs.mock.calls[0]?.[1] as any
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 = mockDebugDirNs.mock.calls[0]?.[1] as any
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 = mockDebugDirNs.mock.calls[0]?.[1] as any
expect(calledWith.responseBody).toMatch(/… \(truncated, 5000 chars\)$/)
expect((calledWith.responseBody as string).length).toBeLessThan(
bigBody.length,
)
})
})

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