@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
145 lines (135 loc) • 5 kB
text/typescript
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:
*
* - `debug` → `console.debug`
* - `info` → `console.info`
* - `warn` → `console.warn`
* - `error` → `console.error`
*
* 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 }`.
*/
export class ConsoleLogger implements Logger {
/** Log a debug-level message; forwards to `console.debug`. */
debug(message: string, meta?: Record<string, unknown>): void {
this.emit('debug', message, meta)
}
/** Log an info-level message; forwards to `console.info`. */
info(message: string, meta?: Record<string, unknown>): void {
this.emit('info', message, meta)
}
/** Log a warning-level message; forwards to `console.warn`. */
warn(message: string, meta?: Record<string, unknown>): void {
this.emit('warn', message, meta)
}
/** Log an error-level message; forwards to `console.error`. */
error(message: string, meta?: Record<string, unknown>): void {
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
}
}
}