Skip to content

Commit 8d43e99

Browse files
gwplclaude
andauthored
feat: add verbose/trace output for API latency debugging (-v to -vvvv) (#56)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 22ad5f8 commit 8d43e99

4 files changed

Lines changed: 280 additions & 1 deletion

File tree

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ import { registerTaskCommand } from './commands/task.js'
2121
import { registerTodayCommand } from './commands/today.js'
2222
import { registerUpcomingCommand } from './commands/upcoming.js'
2323
import { registerWorkspaceCommand } from './commands/workspace.js'
24+
import { initializeLogger } from './lib/logger.js'
2425

2526
program
2627
.name('td')
2728
.description('Todoist CLI')
2829
.version(packageJson.version)
2930
.option('--no-spinner', 'Disable loading animations')
3031
.option('--progress-jsonl [path]', 'Output progress events as JSONL to stderr or file')
32+
.option('-v, --verbose', 'Increase output verbosity (repeat up to 4x: -v, -vv, -vvv, -vvvv)')
3133
.addHelpText(
3234
'after',
3335
`
@@ -57,6 +59,9 @@ registerFilterCommand(program)
5759
registerNotificationCommand(program)
5860
registerSkillCommand(program)
5961

62+
// Initialize verbose logger before parsing so it captures all -v flags
63+
initializeLogger()
64+
6065
program.parseAsync().catch((err: Error) => {
6166
console.error(err.message)
6267
process.exit(1)

src/lib/logger.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/**
2+
* Verbose logging module for debugging API latency and CLI behavior.
3+
*
4+
* Verbosity levels (stackable -v flags):
5+
* -v (1) INFO : commands invoked, API endpoints hit, response status + total timing
6+
* -vv (2) DETAIL : request parameters, response metadata, pagination progress
7+
* -vvv (3) DEBUG : reference resolution steps, HTTP headers, rate-limit info
8+
* -vvvv (4) TRACE : full request/response headers, body sizes, connection details
9+
*
10+
* All output goes to stderr with prefixed tags so it never pollutes stdout data.
11+
* Can also be activated via TD_VERBOSE=1..4 environment variable.
12+
*/
13+
14+
export const Verbosity = {
15+
SILENT: 0,
16+
INFO: 1,
17+
DETAIL: 2,
18+
DEBUG: 3,
19+
TRACE: 4,
20+
} as const
21+
22+
export type Verbosity = (typeof Verbosity)[keyof typeof Verbosity]
23+
24+
const LEVEL_TAG: Record<number, string> = {
25+
[Verbosity.INFO]: 'info',
26+
[Verbosity.DETAIL]: 'detail',
27+
[Verbosity.DEBUG]: 'debug',
28+
[Verbosity.TRACE]: 'trace',
29+
}
30+
31+
class Logger {
32+
private level: Verbosity = Verbosity.SILENT
33+
34+
/** Set verbosity from parsed CLI flags or environment. */
35+
initialize(): void {
36+
// Environment variable takes precedence if set
37+
const envLevel = process.env.TD_VERBOSE
38+
if (envLevel) {
39+
const parsed = parseInt(envLevel, 10)
40+
if (parsed >= Verbosity.INFO && parsed <= Verbosity.TRACE) {
41+
this.level = parsed as Verbosity
42+
}
43+
}
44+
45+
// Count -v flags in argv (supports -v, -vv, -vvv, -vvvv, and repeated --verbose)
46+
const args = process.argv.slice(2)
47+
let cliLevel = 0
48+
for (const arg of args) {
49+
if (arg === '--verbose') {
50+
cliLevel += 1
51+
} else if (/^-v+$/.test(arg)) {
52+
// -v = 1, -vv = 2, -vvv = 3, -vvvv = 4
53+
cliLevel += arg.length - 1
54+
}
55+
}
56+
57+
// CLI flags override env var if higher
58+
if (cliLevel > 0) {
59+
this.level = Math.min(cliLevel, Verbosity.TRACE) as Verbosity
60+
}
61+
62+
if (this.level >= Verbosity.INFO) {
63+
this.log(Verbosity.INFO, `verbose logging enabled (level=${this.level})`)
64+
}
65+
}
66+
67+
getLevel(): Verbosity {
68+
return this.level
69+
}
70+
71+
isEnabled(level: Verbosity = Verbosity.INFO): boolean {
72+
return this.level >= level
73+
}
74+
75+
/** Core log method. Only emits if current verbosity >= requested level. */
76+
log(level: Verbosity, message: string, data?: Record<string, unknown>): void {
77+
if (this.level < level) return
78+
79+
const tag = LEVEL_TAG[level] ?? 'log'
80+
const ts = new Date().toISOString()
81+
let line = `[td:${tag}] ${ts} ${message}`
82+
83+
if (data && Object.keys(data).length > 0) {
84+
const parts: string[] = []
85+
for (const [key, value] of Object.entries(data)) {
86+
if (value === undefined || value === null) continue
87+
parts.push(`${key}=${typeof value === 'object' ? JSON.stringify(value) : value}`)
88+
}
89+
if (parts.length > 0) {
90+
line += ` | ${parts.join(' ')}`
91+
}
92+
}
93+
94+
process.stderr.write(`${line}\n`)
95+
}
96+
97+
/** Level-specific convenience methods. */
98+
info(message: string, data?: Record<string, unknown>): void {
99+
this.log(Verbosity.INFO, message, data)
100+
}
101+
102+
detail(message: string, data?: Record<string, unknown>): void {
103+
this.log(Verbosity.DETAIL, message, data)
104+
}
105+
106+
debug(message: string, data?: Record<string, unknown>): void {
107+
this.log(Verbosity.DEBUG, message, data)
108+
}
109+
110+
trace(message: string, data?: Record<string, unknown>): void {
111+
this.log(Verbosity.TRACE, message, data)
112+
}
113+
114+
/** Log timing of an async operation. Returns the operation result. */
115+
async timed<T>(
116+
level: Verbosity,
117+
label: string,
118+
operation: () => Promise<T>,
119+
extraData?: Record<string, unknown>,
120+
): Promise<T> {
121+
if (this.level < level) {
122+
return operation()
123+
}
124+
125+
const start = performance.now()
126+
this.log(level, `${label} ...started`, extraData)
127+
128+
try {
129+
const result = await operation()
130+
const durationMs = performance.now() - start
131+
this.log(level, `${label} ...done`, {
132+
...extraData,
133+
duration_ms: Math.round(durationMs),
134+
})
135+
return result
136+
} catch (error) {
137+
const durationMs = performance.now() - start
138+
this.log(level, `${label} ...FAILED`, {
139+
...extraData,
140+
duration_ms: Math.round(durationMs),
141+
error: error instanceof Error ? error.message : String(error),
142+
})
143+
throw error
144+
}
145+
}
146+
}
147+
148+
// Global singleton
149+
let logger: Logger | null = null
150+
151+
export function getLogger(): Logger {
152+
if (!logger) {
153+
logger = new Logger()
154+
}
155+
return logger
156+
}
157+
158+
export function initializeLogger(): void {
159+
const log = getLogger()
160+
log.initialize()
161+
162+
// When verbose, patch globalThis.fetch so SDK-internal HTTP calls are also logged.
163+
// This is the only way to get HTTP-level visibility for @doist/todoist-api-typescript
164+
// since it uses fetch() internally and doesn't expose hooks.
165+
if (log.isEnabled()) {
166+
patchGlobalFetch()
167+
}
168+
}
169+
170+
/** Save original fetch and replace with logging wrapper. */
171+
function patchGlobalFetch(): void {
172+
const originalFetch = globalThis.fetch
173+
174+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
175+
const log = getLogger()
176+
const urlStr =
177+
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
178+
const method =
179+
init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')
180+
const urlObj = new URL(urlStr)
181+
182+
log.info(`fetch ${method} ${urlObj.pathname}`)
183+
log.detail(`fetch ${method} ${urlStr}`)
184+
185+
// Log request body size at DETAIL
186+
if (init?.body) {
187+
const bodyStr =
188+
typeof init.body === 'string'
189+
? init.body
190+
: init.body instanceof URLSearchParams
191+
? init.body.toString()
192+
: null
193+
if (bodyStr) {
194+
log.detail('request body', { size_bytes: bodyStr.length })
195+
if (init.body instanceof URLSearchParams) {
196+
log.trace('request body keys', { keys: Array.from(init.body.keys()) })
197+
}
198+
}
199+
}
200+
201+
// Log request headers at TRACE (redact Authorization)
202+
if (log.isEnabled(Verbosity.TRACE) && init?.headers) {
203+
const hdrs: Record<string, string> = {}
204+
if (init.headers instanceof Headers) {
205+
init.headers.forEach((v, k) => {
206+
hdrs[k] = k.toLowerCase() === 'authorization' ? '[REDACTED]' : v
207+
})
208+
} else if (typeof init.headers === 'object') {
209+
for (const [k, v] of Object.entries(init.headers)) {
210+
hdrs[k] = k.toLowerCase() === 'authorization' ? '[REDACTED]' : String(v)
211+
}
212+
}
213+
log.trace('request headers', hdrs)
214+
}
215+
216+
const startTime = performance.now()
217+
const response = await originalFetch(input, init)
218+
const durationMs = Math.round(performance.now() - startTime)
219+
220+
// INFO: status + timing
221+
log.info(`fetch ${method} ${urlObj.pathname} => ${response.status}`, {
222+
duration_ms: durationMs,
223+
})
224+
225+
// DETAIL: content-length
226+
const contentLength = response.headers.get('content-length')
227+
if (contentLength) {
228+
log.detail('response', { content_length: contentLength })
229+
}
230+
231+
// DEBUG: rate-limit and diagnostic headers
232+
const diagHeaders: Record<string, string> = {}
233+
for (const name of [
234+
'x-request-id',
235+
'x-ratelimit-limit',
236+
'x-ratelimit-remaining',
237+
'x-ratelimit-reset',
238+
'retry-after',
239+
'cf-ray',
240+
]) {
241+
const val = response.headers.get(name)
242+
if (val) diagHeaders[name] = val
243+
}
244+
if (Object.keys(diagHeaders).length > 0) {
245+
log.debug('response headers', diagHeaders)
246+
}
247+
248+
// TRACE: all response headers
249+
if (log.isEnabled(Verbosity.TRACE)) {
250+
const allHeaders: Record<string, string> = {}
251+
response.headers.forEach((value, name) => {
252+
if (!name.toLowerCase().includes('set-cookie')) {
253+
allHeaders[name] = value
254+
}
255+
})
256+
log.trace('all response headers', allHeaders)
257+
}
258+
259+
return response
260+
}
261+
}
262+
263+
/** Reset for testing. */
264+
export function resetLogger(): void {
265+
logger = null
266+
}

src/lib/skills/content.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Most list commands also support:
4545
4646
- \`--no-spinner\` - Disable loading animations
4747
- \`--progress-jsonl\` - Machine-readable progress events (JSONL to stderr)
48+
- \`-v, --verbose\` - Verbose output to stderr (repeat: -v info, -vv detail, -vvv debug, -vvvv trace)
4849
4950
## Task References
5051

src/lib/spinner.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,14 @@ class LoadingSpinner {
5959

6060
// Check process arguments for flags that should disable spinner
6161
const args = process.argv
62-
const spinnerDisablingFlags = ['--json', '--ndjson', '--no-spinner', '--progress-jsonl']
62+
const spinnerDisablingFlags = [
63+
'--json',
64+
'--ndjson',
65+
'--no-spinner',
66+
'--progress-jsonl',
67+
'--verbose',
68+
'-v',
69+
]
6370

6471
// Check for both exact matches and prefix matches (to handle --flag=value variants)
6572
return spinnerDisablingFlags.some(

0 commit comments

Comments
 (0)