Skip to content
Merged
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
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.
125 changes: 108 additions & 17 deletions packages/ai/src/logger/console-logger.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,82 @@
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 +86,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