From 5b43c808751c1ba3e309f526925941b1a1046f37 Mon Sep 17 00:00:00 2001 From: Nanook Date: Mon, 22 Jun 2026 15:23:14 +0000 Subject: [PATCH] fix: add HTTP debug logging --- src/cli-schema.ts | 5 +++ src/cli.ts | 7 +++ src/completion/complete.ts | 1 + src/lib/cloud-client.ts | 8 ++++ src/lib/debug-http.ts | 74 ++++++++++++++++++++++++++++++++ src/lib/es-client.ts | 5 ++- src/lib/kibana-client.ts | 8 ++++ test/cli.test.ts | 15 +++++++ test/completion/complete.test.ts | 6 +++ test/lib/cloud-client.test.ts | 35 +++++++++++++++ test/lib/debug-http.test.ts | 71 ++++++++++++++++++++++++++++++ test/lib/es-client.test.ts | 39 +++++++++++++++++ test/lib/kibana-client.test.ts | 35 +++++++++++++++ 13 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/lib/debug-http.ts create mode 100644 test/lib/debug-http.test.ts diff --git a/src/cli-schema.ts b/src/cli-schema.ts index 57185c4c..da4f370a 100644 --- a/src/cli-schema.ts +++ b/src/cli-schema.ts @@ -109,6 +109,11 @@ const ENVIRONMENT: CliEnvironment = { required: false, description: 'Override the Elastic Cloud admin API base URL', }, + { + name: 'ELASTIC_DEBUG', + required: false, + description: 'Set to 1 to print HTTP request and response debug details to stderr', + }, ], configFiles: [ { path: '~/.elasticrc.yml', required: false, description: 'Primary config file (recommended)' }, diff --git a/src/cli.ts b/src/cli.ts index 642091da..4334f655 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -53,6 +53,13 @@ if (hasGlobalFlags) { .option('--output-template ', 'Mustache-like template for custom text output (e.g. "{{id}}: {{name}}")') } program.option('--json', 'output as JSON') +program.option('--debug', 'print HTTP request and response details to stderr') +program.hook('preAction', async (thisCommand) => { + if ((thisCommand.opts() as { debug?: boolean }).debug === true) { + const { setHttpDebugEnabled } = await import('./lib/debug-http.js') + setHttpDebugEnabled(true) + } +}) // preAction hook (skipped for --help paths since the hook never fires) if (!wantsHelp) { diff --git a/src/completion/complete.ts b/src/completion/complete.ts index 5dfc4612..714169f4 100644 --- a/src/completion/complete.ts +++ b/src/completion/complete.ts @@ -158,6 +158,7 @@ export async function buildCompletionTree (rewrittenWords: readonly string[]): P root.option('--use-context ', 'override the active context from the config file') root.option('--command-profile ', 'restrict available commands to a deployment profile') root.option('--json', 'output as JSON') + root.option('--debug', 'print HTTP request and response details to stderr') root.option('--output-fields ', 'comma-separated list of fields to include in output') root.option('--output-template ', 'Mustache-like template for custom text output') diff --git a/src/lib/cloud-client.ts b/src/lib/cloud-client.ts index d9748c9a..8e101d38 100644 --- a/src/lib/cloud-client.ts +++ b/src/lib/cloud-client.ts @@ -5,6 +5,7 @@ import type { HttpMethod } from '../cloud/types.ts' import { getResolvedConfig } from '../config/store.ts' +import { logHttpDebug } from './debug-http.ts' import { isLoopbackUrl } from './is-loopback-host.ts' import { clientHeaders } from './meta.ts' @@ -70,6 +71,13 @@ export class CloudClient { } const response = await this._fetch(url, init) + await logHttpDebug({ + method: params.method, + url, + headers, + ...(typeof init.body === 'string' && { body: init.body }), + response, + }) if (!response.ok) { const text = await response.text() diff --git a/src/lib/debug-http.ts b/src/lib/debug-http.ts new file mode 100644 index 00000000..c6f9574d --- /dev/null +++ b/src/lib/debug-http.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +interface DebugHttpEntry { + method: string + url: string + headers: Record + body?: string + response: Response +} + +const REDACTED_HEADERS = new Set(['authorization', 'x-api-key']) +let cliDebugEnabled = false + +/** + * Sets HTTP debug logging from the parsed root CLI option. + */ +export function setHttpDebugEnabled (value: boolean): void { + cliDebugEnabled = value +} + +/** + * Returns true when HTTP debug logging is enabled by CLI flag or environment. + */ +export function isHttpDebugEnabled (): boolean { + if (process.env['ELASTIC_DEBUG'] === '1') return true + return cliDebugEnabled +} + +function sanitizeHeaders (headers: Record): Record { + const sanitized: Record = {} + for (const [key, value] of Object.entries(headers)) { + sanitized[key] = REDACTED_HEADERS.has(key.toLowerCase()) ? '(redacted)' : value + } + return sanitized +} + +/** + * Writes HTTP request and response debug output to stderr. + * + * The response is cloned before reading its body so callers can still consume + * the original response normally. + */ +export async function logHttpDebug (entry: DebugHttpEntry): Promise { + if (!isHttpDebugEnabled()) return + + const lines = [ + `Request: ${entry.method} ${entry.url}`, + 'Request headers:', + ] + + for (const [key, value] of Object.entries(sanitizeHeaders(entry.headers))) { + lines.push(`${key}: ${value}`) + } + + if (entry.body !== undefined) { + lines.push('Request body:', entry.body) + } + + lines.push(`Response: ${entry.response.status}`) + + try { + const responseBody = await entry.response.clone().text() + if (responseBody.length > 0) { + lines.push('Response body:', responseBody) + } + } catch { + lines.push('Response body: ') + } + + process.stderr.write(`${lines.join('\n')}\n`) +} diff --git a/src/lib/es-client.ts b/src/lib/es-client.ts index fdb9b498..feed7c3e 100644 --- a/src/lib/es-client.ts +++ b/src/lib/es-client.ts @@ -5,6 +5,7 @@ import { getResolvedConfig } from '../config/store.ts' import { buildAuthHeader, type ApiKeyOrBasicAuth } from './auth.ts' +import { logHttpDebug } from './debug-http.ts' import { clientHeaders } from './meta.ts' export interface EsRequestParams { @@ -99,10 +100,10 @@ export class EsClient { } const isHead = params.method.toUpperCase() === 'HEAD' + const method = fetchBody !== undefined && params.method.toUpperCase() === 'GET' ? 'POST' : params.method let response: Response try { - const method = fetchBody !== undefined && params.method.toUpperCase() === 'GET' ? 'POST' : params.method response = await this._fetch(url, { method, headers, @@ -113,6 +114,8 @@ export class EsClient { throw new EsConnectionError(err instanceof Error ? err.message : String(err)) } + await logHttpDebug({ method, url, headers, ...(fetchBody !== undefined && { body: fetchBody }), response }) + if (isHead) { if (response.ok) return true as T if (response.status === 404) return false as T diff --git a/src/lib/kibana-client.ts b/src/lib/kibana-client.ts index d7e171da..8e6da848 100644 --- a/src/lib/kibana-client.ts +++ b/src/lib/kibana-client.ts @@ -7,6 +7,7 @@ import fs from 'node:fs' import path from 'node:path' import { getResolvedConfig } from '../config/store.ts' import { buildAuthHeader, type ApiKeyOrBasicAuth } from './auth.ts' +import { logHttpDebug } from './debug-http.ts' import { isLoopbackUrl } from './is-loopback-host.ts' import { clientHeaders } from './meta.ts' @@ -99,6 +100,13 @@ export class KibanaClient { } const response = await this._fetch(url, init) + await logHttpDebug({ + method, + url, + headers, + ...(typeof init.body === 'string' && { body: init.body }), + response, + }) if (!response.ok) { const text = await response.text() diff --git a/test/cli.test.ts b/test/cli.test.ts index cc1afeef..2e9ada46 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -31,6 +31,7 @@ function makeProgram(): InstanceType { prog.option('--config-file ', 'path to a config file (default: ~/.elasticrc.yml)') prog.option('--use-context ', 'override the active context from the config file') prog.option('--json', 'output as JSON') + prog.option('--debug', 'print HTTP request and response details to stderr') return prog } @@ -54,6 +55,13 @@ describe('elastic CLI -- global flags', () => { assert.ok(!opt.required, '--json should be a boolean flag (no required value)') }) + it('registers --debug as a boolean flag', () => { + const prog = makeProgram() + const opt = prog.options.find((o) => o.long === '--debug') + assert.ok(opt != null, 'expected --debug option') + assert.ok(!opt.required, '--debug should be a boolean flag (no required value)') + }) + it('registers --version as a boolean flag', () => { const prog = makeProgram() const opt = prog.options.find((o) => o.long === '--version') @@ -86,6 +94,12 @@ describe('elastic CLI -- global flags', () => { assert.equal(prog.opts()['json'], true) }) + it('parses --debug as true when provided', () => { + const prog = makeProgram() + prog.parse(['--debug'], { from: 'user' }) + assert.equal(prog.opts()['debug'], true) + }) + it('parses --config-file value correctly', () => { const prog = makeProgram() prog.parse(['--config-file', '/some/path.yml'], { from: 'user' }) @@ -374,6 +388,7 @@ describe('elastic CLI -- --help --json', () => { const { code, stdout } = await runCli(['--help'], { cwd: dir, env: { HOME: dir } }) assert.equal(code, 0, `expected exit code 0, got ${code}`) assert.match(stdout, /^Usage: elastic/m, 'expected text help format') + assert.match(stdout, /--debug/, 'expected --debug in root help') assert.throws(() => JSON.parse(stdout), 'text help should not be valid JSON') } finally { await rm(dir, { recursive: true }) diff --git a/test/completion/complete.test.ts b/test/completion/complete.test.ts index 33efd415..b2df6155 100644 --- a/test/completion/complete.test.ts +++ b/test/completion/complete.test.ts @@ -73,6 +73,12 @@ describe('buildCompletionTree -- top-level commands', () => { assert.ok(opt != null, 'expected --use-context global option') }) + it('exposes global --debug option on the root program', async () => { + const root = await buildCompletionTree([]) + const opt = root.options.find(o => o.long === '--debug') + assert.ok(opt != null, 'expected --debug global option') + }) + it('does not load the es subtree when the first word is unrelated', async () => { const root = await buildCompletionTree(['cloud']) const stack = root.commands.find(c => c.name() === 'stack')! diff --git a/test/lib/cloud-client.test.ts b/test/lib/cloud-client.test.ts index 350836d3..816de5ec 100644 --- a/test/lib/cloud-client.test.ts +++ b/test/lib/cloud-client.test.ts @@ -10,6 +10,22 @@ import { setResolvedConfig } from '../../src/config/store.ts' import type { ResolvedConfig } from '../../src/config/types.ts' import { clientHeaders } from '../../src/lib/meta.ts' +async function captureDebugOutput (fn: () => Promise): Promise { + const chunks: string[] = [] + const origWrite = process.stderr.write + const previousDebug = process.env['ELASTIC_DEBUG'] + process.env['ELASTIC_DEBUG'] = '1' + process.stderr.write = ((chunk: string) => { chunks.push(chunk); return true }) as typeof process.stderr.write + try { + await fn() + } finally { + process.stderr.write = origWrite + if (previousDebug === undefined) delete process.env['ELASTIC_DEBUG'] + else process.env['ELASTIC_DEBUG'] = previousDebug + } + return chunks.join('') +} + afterEach(() => { _testResetCloudClient() setResolvedConfig(undefined as unknown as ResolvedConfig) @@ -162,6 +178,25 @@ describe('getCloudClient', () => { assert.deepEqual(result, {}) }) + it('logs sanitized debug output when ELASTIC_DEBUG=1', async () => { + setResolvedConfig({ context: { cloud: { url: 'https://api.elastic-cloud.com', auth: { api_key: 'secret-key' } } } }) + const client = getCloudClient() + client._testSetFetch((() => + Promise.resolve(new Response('{"id":"deployment-1"}', { status: 200 })) + ) as typeof fetch) + + const stderr = await captureDebugOutput(() => + client.request({ method: 'POST', path: '/api/v1/deployments', body: { name: 'test' } }) + ) + + assert.match(stderr, /POST https:\/\/api\.elastic-cloud\.com\/api\/v1\/deployments/) + assert.match(stderr, /Authorization: \(redacted\)/) + assert.match(stderr, /\{"name":"test"\}/) + assert.match(stderr, /Response: 200/) + assert.match(stderr, /\{"id":"deployment-1"\}/) + assert.doesNotMatch(stderr, /secret-key/) + }) + it('warns on plaintext HTTP for non-localhost URLs (#107)', () => { const chunks: string[] = [] const origWrite = process.stderr.write diff --git a/test/lib/debug-http.test.ts b/test/lib/debug-http.test.ts new file mode 100644 index 00000000..f0f954cc --- /dev/null +++ b/test/lib/debug-http.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { isHttpDebugEnabled, logHttpDebug, setHttpDebugEnabled } from '../../src/lib/debug-http.ts' + +function captureStderr (fn: () => Promise | unknown): Promise { + const chunks: string[] = [] + const origWrite = process.stderr.write + process.stderr.write = ((chunk: string) => { chunks.push(chunk); return true }) as typeof process.stderr.write + return Promise.resolve() + .then(fn) + .finally(() => { + process.stderr.write = origWrite + }) + .then(() => chunks.join('')) +} + +describe('HTTP debug logging', () => { + it('is enabled by ELASTIC_DEBUG=1', () => { + const previous = process.env['ELASTIC_DEBUG'] + try { + process.env['ELASTIC_DEBUG'] = '1' + assert.equal(isHttpDebugEnabled(), true) + } finally { + if (previous === undefined) delete process.env['ELASTIC_DEBUG'] + else process.env['ELASTIC_DEBUG'] = previous + } + }) + + it('is enabled by the root --debug option', () => { + try { + setHttpDebugEnabled(true) + assert.equal(isHttpDebugEnabled(), true) + } finally { + setHttpDebugEnabled(false) + } + }) + + it('redacts credentials and logs request and response details to stderr', async () => { + const stderr = await captureStderr(async () => { + setHttpDebugEnabled(true) + await logHttpDebug({ + method: 'POST', + url: 'https://example.com/_search', + headers: { + Authorization: 'ApiKey secret', + 'X-Api-Key': 'another-secret', + Accept: 'application/json', + }, + body: '{"query":{"match_all":{}}}', + response: new Response('{"ok":true}', { status: 201 }), + }) + setHttpDebugEnabled(false) + }) + + assert.match(stderr, /POST https:\/\/example\.com\/_search/) + assert.match(stderr, /Authorization: \(redacted\)/) + assert.match(stderr, /X-Api-Key: \(redacted\)/) + assert.match(stderr, /Accept: application\/json/) + assert.match(stderr, /\{"query":\{"match_all":\{\}\}\}/) + assert.match(stderr, /Response: 201/) + assert.match(stderr, /\{"ok":true\}/) + assert.doesNotMatch(stderr, /secret/) + assert.doesNotMatch(stderr, /another-secret/) + }) + +}) diff --git a/test/lib/es-client.test.ts b/test/lib/es-client.test.ts index 286d07a6..64dc448b 100644 --- a/test/lib/es-client.test.ts +++ b/test/lib/es-client.test.ts @@ -73,6 +73,26 @@ describe('EsClient.request', () => { return new EsClient('http://localhost:9200', auth ?? { api_key: 'test-key' }) } + async function captureDebugOutput (fn: () => Promise): Promise<{ stdout: string; stderr: string }> { + const stdoutChunks: string[] = [] + const stderrChunks: string[] = [] + const origStdout = process.stdout.write + const origStderr = process.stderr.write + const previousDebug = process.env['ELASTIC_DEBUG'] + process.env['ELASTIC_DEBUG'] = '1' + process.stdout.write = ((chunk: string) => { stdoutChunks.push(chunk); return true }) as typeof process.stdout.write + process.stderr.write = ((chunk: string) => { stderrChunks.push(chunk); return true }) as typeof process.stderr.write + try { + await fn() + } finally { + process.stdout.write = origStdout + process.stderr.write = origStderr + if (previousDebug === undefined) delete process.env['ELASTIC_DEBUG'] + else process.env['ELASTIC_DEBUG'] = previousDebug + } + return { stdout: stdoutChunks.join(''), stderr: stderrChunks.join('') } + } + it('sends x-elastic-client-meta on every request', async () => { const client = makeClient() let capturedHeaders: Record = {} @@ -215,6 +235,25 @@ describe('EsClient.request', () => { assert.deepEqual(result, { hits: { total: 1 } }) }) + it('logs sanitized debug output to stderr without writing stdout', async () => { + const client = makeClient({ api_key: 'secret-key' }) + client._testSetFetch((() => + Promise.resolve(new Response('{"acknowledged":true}', { status: 202, headers: { 'content-type': 'application/json' } })) + ) as typeof fetch) + + const { stdout, stderr } = await captureDebugOutput(() => + client.request({ method: 'POST', path: '/_search', body: { query: { match_all: {} } } }) + ) + + assert.equal(stdout, '') + assert.match(stderr, /POST http:\/\/localhost:9200\/_search/) + assert.match(stderr, /Authorization: \(redacted\)/) + assert.match(stderr, /\{"query":\{"match_all":\{\}\}\}/) + assert.match(stderr, /Response: 202/) + assert.match(stderr, /\{"acknowledged":true\}/) + assert.doesNotMatch(stderr, /secret-key/) + }) + it('returns raw string when response content-type is text', async () => { const client = makeClient() client._testSetFetch((() => diff --git a/test/lib/kibana-client.test.ts b/test/lib/kibana-client.test.ts index 3108733f..1e81bb8f 100644 --- a/test/lib/kibana-client.test.ts +++ b/test/lib/kibana-client.test.ts @@ -64,6 +64,22 @@ describe('KibanaClient.request', () => { return new KibanaClient('http://localhost:5601', auth) } + async function captureDebugOutput (fn: () => Promise): Promise { + const chunks: string[] = [] + const origWrite = process.stderr.write + const previousDebug = process.env['ELASTIC_DEBUG'] + process.env['ELASTIC_DEBUG'] = '1' + process.stderr.write = ((chunk: string) => { chunks.push(chunk); return true }) as typeof process.stderr.write + try { + await fn() + } finally { + process.stderr.write = origWrite + if (previousDebug === undefined) delete process.env['ELASTIC_DEBUG'] + else process.env['ELASTIC_DEBUG'] = previousDebug + } + return chunks.join('') + } + it('includes x-elastic-client-meta and user-agent on every request', async () => { const client = makeClient() let capturedHeaders: Record = {} @@ -178,6 +194,25 @@ describe('KibanaClient.request', () => { assert.deepEqual(result, {}) }) + it('logs sanitized debug output when ELASTIC_DEBUG=1', async () => { + const client = makeClient({ username: 'elastic', password: 'changeme' }) + client._testSetFetch((() => + Promise.resolve(new Response('{"id":"1"}', { status: 200 })) + ) as typeof fetch) + + const stderr = await captureDebugOutput(() => + client.request({ method: 'POST', path: '/api/saved_objects', body: { attributes: { title: 'Dashboard' } } }) + ) + + assert.match(stderr, /POST http:\/\/localhost:5601\/api\/saved_objects/) + assert.match(stderr, /Authorization: \(redacted\)/) + assert.match(stderr, /kbn-xsrf: true/) + assert.match(stderr, /\{"attributes":\{"title":"Dashboard"\}\}/) + assert.match(stderr, /Response: 200/) + assert.match(stderr, /\{"id":"1"\}/) + assert.doesNotMatch(stderr, /changeme/) + }) + it('sets redirect to error', async () => { const client = makeClient() let capturedInit: RequestInit = {}