Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/console-logger-workerd-meta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai': patch
---

Fix the default debug logger dropping `meta` payloads on Cloudflare Workers / workerd (#730). `ConsoleLogger` previously rendered `meta` with `console.dir`, which workerd never forwards to the terminal — debug mode printed category headlines but no request bodies, chunk contents, or `RUN_ERROR` payloads. The logger now detects the runtime: Node keeps the depth-unlimited `console.dir` dump, Cloudflare Workers renders `meta` as circular-safe pretty-printed JSON (workerd's own inspect truncates nested objects), and other runtimes (browsers, Deno, Bun) receive `meta` as an extra console argument so devtools keep collapsible trees. Detection checks workerd's `navigator.userAgent` marker before `process.versions.node`, since `nodejs_compat` emulates a Node version string.
129 changes: 112 additions & 17 deletions packages/ai/src/logger/console-logger.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,86 @@
import type { Logger } from './types'

/**
* `util.inspect` options used with `console.dir` on Node so deeply nested
* structures (e.g. provider chunk payloads with `usage`, `output`,
* `reasoning`, `tools`) render in full instead of truncating to
* `[Object]` / `[Array]`.
*/
const DIR_OPTIONS = { depth: null, colors: true } as const

/**
* How `meta` should be rendered on the current runtime:
*
* - `dir` — Node. `console.dir(meta, { depth: null, colors: true })` gives a
* depth-unlimited, colored inspect dump.
* - `json` — Cloudflare Workers / workerd. workerd never forwards
* `console.dir` output to the terminal (with or without options), and its
* own inspect of extra console arguments truncates nested objects, so the
* payload is appended as circular-safe pretty-printed JSON instead.
* - `arg` — everything else (browsers, Deno, Bun). `meta` is passed as an
* extra console argument: devtools keep collapsible object trees and the
* runtime's inspect handles circular references natively.
*/
type MetaStrategy = 'dir' | 'json' | 'arg'

function resolveMetaStrategy(): MetaStrategy {
// workerd must be detected before the Node check: under the `nodejs_compat`
// flag it emulates `process.versions.node`, but still drops `console.dir`.
try {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- navigator is missing on Node < 21 despite the DOM lib typing it as always present
if (globalThis.navigator?.userAgent === 'Cloudflare-Workers') return 'json'
} catch {
// A locked-down runtime with a throwing `userAgent` getter is not workerd;
// fall through to the remaining checks rather than crash the log call.
}
if (
typeof process !== 'undefined' &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- a partial process global (bundler shims) may lack versions
typeof process.versions?.node === 'string'
) {
return 'dir'
}
return 'arg'
}

/**
* `JSON.stringify` hardened for debug payloads: circular references collapse
* to `"[Circular]"`, `Error` instances expand to `name`/`message`/`stack`
* (they would otherwise stringify to `{}`), and `bigint` values become
* strings (they would otherwise throw). Never throws — falls back to
* `String(value)` and, if even that coercion throws, a placeholder.
*/
function stringifyMetaSafely(value: unknown): string {
const seen = new WeakSet<object>()
try {
return JSON.stringify(
value,
(_key, entry: unknown) => {
if (typeof entry === 'bigint') return entry.toString()
if (entry instanceof Error) {
return {
name: entry.name,
message: entry.message,
stack: entry.stack,
}
}
if (typeof entry === 'object' && entry !== null) {
if (seen.has(entry)) return '[Circular]'
seen.add(entry)
}
return entry
},
2,
)
} catch {
try {
return String(value)
} catch {
return '[Unserializable meta]'
}
}
}

/**
* Default `Logger` implementation that routes each level to the matching
* `console` method:
Expand All @@ -9,41 +90,55 @@ import type { Logger } from './types'
* - `warn` → `console.warn`
* - `error` → `console.error`
*
* When a `meta` object is supplied, the message is logged first and the meta
* object is then printed via `console.dir(meta, { depth: null, colors: true })`
* so deeply nested structures (e.g. provider chunk payloads with `usage`,
* `output`, `reasoning`, `tools`) render in full instead of truncating to
* `[Object]` / `[Array]`. On Node this produces a depth-unlimited inspect
* dump; browsers present the object as an interactive tree (extra options
* are ignored).
* When a `meta` object is supplied it is rendered with the strategy that
* actually surfaces it on the current runtime (see {@link MetaStrategy}):
* depth-unlimited `console.dir` on Node, circular-safe JSON on Cloudflare
* Workers, and an extra console argument everywhere else.
*
* This is the logger used when `debug` is enabled on any activity and no
* custom `logger` is supplied via `debug: { logger }`.
*/
const DIR_OPTIONS = { depth: null, colors: true } as const

export class ConsoleLogger implements Logger {
/** Log a debug-level message; forwards to `console.debug`. */
debug(message: string, meta?: Record<string, unknown>): void {
console.debug(message)
if (meta !== undefined) console.dir(meta, DIR_OPTIONS)
this.emit('debug', message, meta)
}

/** Log an info-level message; forwards to `console.info`. */
info(message: string, meta?: Record<string, unknown>): void {
console.info(message)
if (meta !== undefined) console.dir(meta, DIR_OPTIONS)
this.emit('info', message, meta)
}

/** Log a warning-level message; forwards to `console.warn`. */
warn(message: string, meta?: Record<string, unknown>): void {
console.warn(message)
if (meta !== undefined) console.dir(meta, DIR_OPTIONS)
this.emit('warn', message, meta)
}

/** Log an error-level message; forwards to `console.error`. */
error(message: string, meta?: Record<string, unknown>): void {
console.error(message)
if (meta !== undefined) console.dir(meta, DIR_OPTIONS)
this.emit('error', message, meta)
}

private emit(
level: 'debug' | 'info' | 'warn' | 'error',
message: string,
meta?: Record<string, unknown>,
): void {
if (meta === undefined) {
console[level](message)
return
}
switch (resolveMetaStrategy()) {
case 'dir':
console[level](message)
console.dir(meta, DIR_OPTIONS)
break
case 'json':
console[level](`${message}\n${stringifyMetaSafely(meta)}`)
break
case 'arg':
console[level](message, meta)
break
}
}
}
8 changes: 4 additions & 4 deletions packages/ai/src/logger/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@
export interface Logger {
/**
* Called for chunk-level diagnostic output (raw provider chunks, per-chunk output, agent-loop iteration markers).
* @param meta Structured data forwarded to the underlying logger. Loggers like pino will preserve this as a structured record; console-based loggers pass it as the second argument to `console.<level>`.
* @param meta Structured data forwarded to the underlying logger. Loggers like pino will preserve this as a structured record; the default `ConsoleLogger` renders it with a runtime-appropriate strategy (depth-unlimited `console.dir` on Node, JSON appended to the message on Cloudflare Workers, a second `console.<level>` argument elsewhere).
*/
debug: (message: string, meta?: Record<string, unknown>) => void
/**
* Called for notable informational events (outgoing requests, tool invocations, middleware transitions).
* @param meta Structured data forwarded to the underlying logger. Loggers like pino will preserve this as a structured record; console-based loggers pass it as the second argument to `console.<level>`.
* @param meta Structured data forwarded to the underlying logger. Loggers like pino will preserve this as a structured record; the default `ConsoleLogger` renders it with a runtime-appropriate strategy (depth-unlimited `console.dir` on Node, JSON appended to the message on Cloudflare Workers, a second `console.<level>` argument elsewhere).
*/
info: (message: string, meta?: Record<string, unknown>) => void
/**
* Called for notable warnings that don't halt execution (deprecations, recoverable anomalies).
* @param meta Structured data forwarded to the underlying logger. Loggers like pino will preserve this as a structured record; console-based loggers pass it as the second argument to `console.<level>`.
* @param meta Structured data forwarded to the underlying logger. Loggers like pino will preserve this as a structured record; the default `ConsoleLogger` renders it with a runtime-appropriate strategy (depth-unlimited `console.dir` on Node, JSON appended to the message on Cloudflare Workers, a second `console.<level>` argument elsewhere).
*/
warn: (message: string, meta?: Record<string, unknown>) => void
/**
* Called for caught exceptions throughout the pipeline.
* @param meta Structured data forwarded to the underlying logger. Loggers like pino will preserve this as a structured record; console-based loggers pass it as the second argument to `console.<level>`.
* @param meta Structured data forwarded to the underlying logger. Loggers like pino will preserve this as a structured record; the default `ConsoleLogger` renders it with a runtime-appropriate strategy (depth-unlimited `console.dir` on Node, JSON appended to the message on Cloudflare Workers, a second `console.<level>` argument elsewhere).
*/
error: (message: string, meta?: Record<string, unknown>) => void
}
Expand Down
96 changes: 95 additions & 1 deletion packages/ai/tests/logger/console-logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('ConsoleLogger', () => {
warnSpy.mockClear()
errorSpy.mockClear()
dirSpy.mockClear()
vi.unstubAllGlobals()
})

it('routes debug to console.debug', () => {
Expand All @@ -40,11 +41,15 @@ describe('ConsoleLogger', () => {
expect(dirSpy).not.toHaveBeenCalled()
})

it('prints meta via console.dir with depth: null when provided', () => {
it('prints meta via console.dir with depth: null on Node', () => {
const meta = { key: 1 }
new ConsoleLogger().debug('msg', meta)
expect(debugSpy).toHaveBeenCalledWith('msg')
expect(dirSpy).toHaveBeenCalledWith(meta, { depth: null, colors: true })
// The message must precede the meta dump.
expect(debugSpy.mock.invocationCallOrder[0]).toBeLessThan(
dirSpy.mock.invocationCallOrder[0] ?? 0,
)
})

it('omits console.dir when meta is not provided', () => {
Expand All @@ -54,6 +59,95 @@ describe('ConsoleLogger', () => {
expect(dirSpy).not.toHaveBeenCalled()
})

describe('on Cloudflare Workers (workerd)', () => {
// workerd drops console.dir output entirely and shallow-truncates extra
// console arguments, so meta is appended as pretty-printed JSON. With
// nodejs_compat workerd also emulates process.versions.node — the
// userAgent marker must win over the Node check.
const stubWorkerd = () => {
vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' })
vi.stubGlobal('process', { versions: { node: '22.14.0' } })
}

it('appends meta as full-depth JSON instead of console.dir', () => {
stubWorkerd()
const meta = { a: { b: { c: { d: 'deep' } } } }
new ConsoleLogger().debug('msg', meta)
expect(dirSpy).not.toHaveBeenCalled()
const logged = debugSpy.mock.calls[0]?.[0]
expect(logged).toContain('msg\n')
expect(logged).toContain('"d": "deep"')
})

it('does the same for info, warn, and error', () => {
stubWorkerd()
const meta = { key: 1 }
const logger = new ConsoleLogger()
logger.info('i', meta)
logger.warn('w', meta)
logger.error('e', meta)
expect(infoSpy.mock.calls[0]?.[0]).toContain('"key": 1')
expect(warnSpy.mock.calls[0]?.[0]).toContain('"key": 1')
expect(errorSpy.mock.calls[0]?.[0]).toContain('"key": 1')
expect(dirSpy).not.toHaveBeenCalled()
})

it('survives circular references in meta', () => {
stubWorkerd()
const meta: Record<string, unknown> = { name: 'loop' }
meta.self = meta
new ConsoleLogger().debug('msg', meta)
const logged = debugSpy.mock.calls[0]?.[0]
expect(logged).toContain('"self": "[Circular]"')
expect(logged).toContain('"name": "loop"')
})

it('serializes Error instances with name, message, and stack', () => {
stubWorkerd()
new ConsoleLogger().error('boom', { error: new Error('upstream 401') })
const logged = errorSpy.mock.calls[0]?.[0]
expect(logged).toContain('"message": "upstream 401"')
expect(logged).toContain('"name": "Error"')
})

it('serializes bigint values instead of throwing', () => {
stubWorkerd()
new ConsoleLogger().debug('msg', { tokens: 42n })
expect(debugSpy.mock.calls[0]?.[0]).toContain('"tokens": "42"')
})

it('still logs message-only calls with a single plain argument', () => {
stubWorkerd()
new ConsoleLogger().debug('msg')
expect(debugSpy).toHaveBeenCalledWith('msg')
expect(debugSpy.mock.calls[0]?.length).toBe(1)
})

it('never throws, even for meta that defeats both JSON and String()', () => {
stubWorkerd()
const hostile = {
toJSON() {
throw new Error('no json')
},
[Symbol.toPrimitive]() {
throw new Error('no string')
},
}
expect(() => new ConsoleLogger().debug('msg', hostile)).not.toThrow()
expect(debugSpy.mock.calls[0]?.[0]).toContain('[Unserializable meta]')
})
})

describe('on non-Node runtimes without the workerd marker', () => {
it('passes meta as a console argument when process.versions.node is absent', () => {
vi.stubGlobal('process', {})
const meta = { key: 1 }
new ConsoleLogger().error('msg', meta)
expect(errorSpy).toHaveBeenCalledWith('msg', meta)
expect(dirSpy).not.toHaveBeenCalled()
})
})

it('implements the Logger interface', () => {
const logger: import('../../src/logger/types').Logger = new ConsoleLogger()
expect(typeof logger.debug).toBe('function')
Expand Down
Loading
Loading