diff --git a/.changeset/console-logger-workerd-meta.md b/.changeset/console-logger-workerd-meta.md new file mode 100644 index 000000000..254749823 --- /dev/null +++ b/.changeset/console-logger-workerd-meta.md @@ -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. diff --git a/packages/ai/src/logger/console-logger.ts b/packages/ai/src/logger/console-logger.ts index 0be666576..f790b1071 100644 --- a/packages/ai/src/logger/console-logger.ts +++ b/packages/ai/src/logger/console-logger.ts @@ -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() + 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: @@ -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): 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): 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): 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): 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, + ): 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 + } } } diff --git a/packages/ai/src/logger/types.ts b/packages/ai/src/logger/types.ts index a44157b4e..41611cf30 100644 --- a/packages/ai/src/logger/types.ts +++ b/packages/ai/src/logger/types.ts @@ -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.`. + * @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.` argument elsewhere). */ debug: (message: string, meta?: Record) => 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.`. + * @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.` argument elsewhere). */ info: (message: string, meta?: Record) => 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.`. + * @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.` argument elsewhere). */ warn: (message: string, meta?: Record) => 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.`. + * @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.` argument elsewhere). */ error: (message: string, meta?: Record) => void } diff --git a/packages/ai/tests/logger/console-logger.test.ts b/packages/ai/tests/logger/console-logger.test.ts index b5e88c92c..e9e8704ea 100644 --- a/packages/ai/tests/logger/console-logger.test.ts +++ b/packages/ai/tests/logger/console-logger.test.ts @@ -14,6 +14,7 @@ describe('ConsoleLogger', () => { warnSpy.mockClear() errorSpy.mockClear() dirSpy.mockClear() + vi.unstubAllGlobals() }) it('routes debug to console.debug', () => { @@ -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', () => { @@ -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 = { 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') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab84c8703..3fe7276d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -645,7 +645,7 @@ importers: version: 0.561.0(react@19.2.3) nitro: specifier: latest - version: 3.0.260429-beta(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(miniflare@4.20260504.0)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.0.260429-beta(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(miniflare@4.20260609.0)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1276,7 +1276,7 @@ importers: version: link:../openai-base openai: specifier: ^6.41.0 - version: 6.41.0(ws@8.19.0)(zod@4.3.6) + version: 6.41.0(ws@8.20.1)(zod@4.3.6) zod: specifier: ^4.0.0 version: 4.3.6 @@ -1307,7 +1307,7 @@ importers: version: link:../openai-base openai: specifier: ^6.41.0 - version: 6.41.0(ws@8.19.0)(zod@4.3.6) + version: 6.41.0(ws@8.20.1)(zod@4.3.6) zod: specifier: ^4.0.0 version: 4.3.6 @@ -1418,7 +1418,7 @@ importers: version: link:../openai-base openai: specifier: ^6.41.0 - version: 6.41.0(ws@8.19.0)(zod@4.3.6) + version: 6.41.0(ws@8.20.1)(zod@4.3.6) devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1755,7 +1755,7 @@ importers: version: link:../ai-utils openai: specifier: ^6.41.0 - version: 6.41.0(ws@8.19.0)(zod@4.3.6) + version: 6.41.0(ws@8.20.1)(zod@4.3.6) devDependencies: '@tanstack/ai': specifier: workspace:* @@ -1956,6 +1956,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.2 version: 5.1.2(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + miniflare: + specifier: ^4.20260609.0 + version: 4.20260609.0 typescript: specifier: 5.9.3 version: 5.9.3 @@ -2753,30 +2756,60 @@ packages: cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-64@1.20260609.1': + resolution: {integrity: sha512-AK8tYLQm+8BqQMzjZ55ZfuhfIm1eCkj+Ykxz6kWXojdACwjjU03MrwdM9fBDdgzU3upXOs4e1scOFHySlfVQjA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260504.1': resolution: {integrity: sha512-7iMXxIU0N5KklZpQm2kuwTm0XtrpHXNqhejJyGquky8gSTnm31zBdutjMekH8VRr6ckbvZIl6lvqXzXdfOEojg==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260609.1': + resolution: {integrity: sha512-4kKXfr7ZHU6xQ/R9ShdSuj1A1bEouoRcHzUWdjnuMPBlRsAAVanlxAVYISotFUulLEinayOpRFbhpsfwzrpSSw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-linux-64@1.20260504.1': resolution: {integrity: sha512-YLB0EH5FQV++oWlalFgPF3p2Bp3dn/D6RWNMw0ukEC8gKnNX6o61A+dlFUl8hRD35ja1zKRxGFUojs4U2+MoJA==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-64@1.20260609.1': + resolution: {integrity: sha512-T2Ebir2OPHAvvZ0HUh5mi1lN8q30sVi4lf7LIpc28AHoWtoOmJ0jA5AJK4IYJm1MKEbBldq+QsckaHOCQFmRpQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260504.1': resolution: {integrity: sha512-FAh/82jDXDArfn9xDih6f/IJfF2SHXBb4nFeQAyHyvXrn18zM6Q3yl2Vj0U7LybbNbmu7TNGghwaM2NoSQS+0A==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260609.1': + resolution: {integrity: sha512-INfcYoSsKqEIvPL69/3RkqYoP8WUR0VEN6loWN/3tekXLoJrVOj3E5NjIetsdS8MJN6zc3st/ae4bMuWRRzoDg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-windows-64@1.20260504.1': resolution: {integrity: sha512-QUg/B3dfrK/KHHHhiJzdkLkTg5mG7lA3t8iplbBoUa3XKCLOHOOXhbU4WSYlLqg8YnsQ6XLZ1HVA99fmZhJh7A==} engines: {node: '>=16'} cpu: [x64] os: [win32] + '@cloudflare/workerd-windows-64@1.20260609.1': + resolution: {integrity: sha512-EWhfxKI1aqUr7S8xuGxgmRCumEzB8iSsCIz6oEqJN+3pZuW3EWiKDGFW4EY1BmwNINLW1eO5VMGYb8Fj6FVYxA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workers-types@4.20260317.1': resolution: {integrity: sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==} @@ -10888,6 +10921,11 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + miniflare@4.20260609.0: + resolution: {integrity: sha512-4ZfNh9ACDa/mKKQvTSO2vigyQS2MB7dEU02KRPle4FqL7S6nek+2Fq6WGzazZbt1OORYgb4OGVLnOCx+My2NNA==} + engines: {node: '>=22.0.0'} + hasBin: true + minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -12900,10 +12938,6 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.16.0: - resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} - engines: {node: '>=20.18.1'} - undici@7.21.0: resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} engines: {node: '>=20.18.1'} @@ -13717,6 +13751,11 @@ packages: engines: {node: '>=16'} hasBin: true + workerd@1.20260609.1: + resolution: {integrity: sha512-KF/Y/8f4VoXCk87NuU6RqmO0X5fdzcrxU3XzAgoPUpnH9t1ZyzRgX1O/9sJvjItxroCBTEBzKssda02Dz9i6BA==} + engines: {node: '>=16'} + hasBin: true + wrangler@4.88.0: resolution: {integrity: sha512-f470QwbeT/JM1S0duq+sLtkss7UBxIFDtYHgujv9tdQUyA/dLGDq51am0rqrsuFtCi97lTM1P5sqtt8xra1AlA==} engines: {node: '>=22.0.0'} @@ -13790,6 +13829,18 @@ packages: utf-8-validate: optional: true + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xcode@3.0.1: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} @@ -14782,18 +14833,33 @@ snapshots: '@cloudflare/workerd-darwin-64@1.20260504.1': optional: true + '@cloudflare/workerd-darwin-64@1.20260609.1': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20260504.1': optional: true + '@cloudflare/workerd-darwin-arm64@1.20260609.1': + optional: true + '@cloudflare/workerd-linux-64@1.20260504.1': optional: true + '@cloudflare/workerd-linux-64@1.20260609.1': + optional: true + '@cloudflare/workerd-linux-arm64@1.20260504.1': optional: true + '@cloudflare/workerd-linux-arm64@1.20260609.1': + optional: true + '@cloudflare/workerd-windows-64@1.20260504.1': optional: true + '@cloudflare/workerd-windows-64@1.20260609.1': + optional: true + '@cloudflare/workers-types@4.20260317.1': {} '@copilotkit/aimock@1.29.0(vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': @@ -15870,7 +15936,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.10.0 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -20817,7 +20883,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.16.0 + undici: 7.24.8 whatwg-mimetype: 4.0.0 chokidar@3.6.0: @@ -21386,14 +21452,14 @@ snapshots: env-paths@2.2.1: {} - env-runner@0.1.7(miniflare@4.20260504.0): + env-runner@0.1.7(miniflare@4.20260609.0): dependencies: crossws: 0.4.5(srvx@0.11.15) exsolve: 1.0.8 httpxy: 0.5.1 srvx: 0.11.15 optionalDependencies: - miniflare: 4.20260504.0 + miniflare: 4.20260609.0 error-ex@1.3.4: dependencies: @@ -24016,6 +24082,18 @@ snapshots: - bufferutil - utf-8-validate + miniflare@4.20260609.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260609.1 + ws: 8.20.1 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -24182,12 +24260,12 @@ snapshots: - sqlite3 - uploadthing - nitro@3.0.260429-beta(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(miniflare@4.20260504.0)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + nitro@3.0.260429-beta(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(miniflare@4.20260609.0)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 crossws: 0.4.5(srvx@0.11.15) db0: 0.3.4 - env-runner: 0.1.7(miniflare@4.20260504.0) + env-runner: 0.1.7(miniflare@4.20260609.0) h3: 2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)) hookable: 6.1.1 nf3: 0.3.16 @@ -24647,9 +24725,9 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.41.0(ws@8.19.0)(zod@4.3.6): + openai@6.41.0(ws@8.20.1)(zod@4.3.6): optionalDependencies: - ws: 8.19.0 + ws: 8.20.1 zod: 4.3.6 optionator@0.9.4: @@ -26640,8 +26718,6 @@ snapshots: undici-types@7.16.0: {} - undici@7.16.0: {} - undici@7.21.0: {} undici@7.24.8: {} @@ -27389,6 +27465,14 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260504.1 '@cloudflare/workerd-windows-64': 1.20260504.1 + workerd@1.20260609.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260609.1 + '@cloudflare/workerd-darwin-arm64': 1.20260609.1 + '@cloudflare/workerd-linux-64': 1.20260609.1 + '@cloudflare/workerd-linux-arm64': 1.20260609.1 + '@cloudflare/workerd-windows-64': 1.20260609.1 + wrangler@4.88.0(@cloudflare/workers-types@4.20260317.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 @@ -27433,6 +27517,8 @@ snapshots: ws@8.19.0: {} + ws@8.20.1: {} + xcode@3.0.1: dependencies: simple-plist: 1.3.1 diff --git a/testing/e2e/package.json b/testing/e2e/package.json index af0e4ad99..68381f527 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -53,6 +53,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", + "miniflare": "^4.20260609.0", "typescript": "5.9.3", "vite": "^7.3.3" } diff --git a/testing/e2e/tests/workerd-console-logger.spec.ts b/testing/e2e/tests/workerd-console-logger.spec.ts new file mode 100644 index 000000000..c5eb92130 --- /dev/null +++ b/testing/e2e/tests/workerd-console-logger.spec.ts @@ -0,0 +1,100 @@ +import path from 'node:path' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { expect, test } from '@playwright/test' +import { Miniflare } from 'miniflare' + +/** + * Regression test for #730: the default debug logger (`ConsoleLogger`) + * dropped every `meta` payload on Cloudflare Workers because workerd never + * forwards `console.dir` output to the terminal. These tests run the BUILT + * logger inside real workerd (via Miniflare, with `nodejs_compat` enabled — + * which also emulates `process.versions.node`, the trap that defeats naive + * Node detection) and assert the payloads actually reach the runtime's log + * stream. + * + * Policy exception — no aimock: this suite's aimock convention exists to + * mock provider LLM responses, but the unit under test here is console + * rendering inside the workerd runtime, a code path no provider HTTP ever + * reaches. Wiring aimock in would require bundling the full `chat()` + + * adapter stack into the Miniflare worker while adding no coverage of the + * behavior being fixed; the runtime itself (real workerd) is the thing + * being exercised instead. + * + * Requires `@tanstack/ai` to be built (`pnpm build`), same as every other + * spec in this suite. + */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const LOGGER_DIST = path.resolve( + __dirname, + '../../../packages/ai/dist/esm/logger/console-logger.js', +) + +/** + * Build a single-module worker script: the compiled ConsoleLogger source + * (self-contained, no imports) plus a fetch handler that exercises it. + */ +async function buildWorkerScript(): Promise { + const loggerSource = await readFile(LOGGER_DIST, 'utf8') + return `${loggerSource} +export default { + fetch() { + const logger = new ConsoleLogger() + logger.debug('E2E-DEBUG-HEADLINE', { + payload: { nested: { deeper: { deepest: { marker: 'E2E-META-DEPTH-5' } } } }, + }) + logger.error('E2E-ERROR-HEADLINE', { cause: new Error('E2E-UPSTREAM-401') }) + const circular = { name: 'E2E-CIRCULAR' } + circular.self = circular + logger.warn('E2E-WARN-HEADLINE', circular) + return new Response('ok') + }, +} +` +} + +test.describe('ConsoleLogger on real workerd', () => { + test('meta payloads reach the log stream at full depth', async () => { + const chunks: Array = [] + const mf = new Miniflare({ + modules: true, + script: await buildWorkerScript(), + compatibilityDate: '2025-01-01', + compatibilityFlags: ['nodejs_compat'], + handleRuntimeStdio(stdout, stderr) { + stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) + stderr.on('data', (chunk: Buffer) => chunks.push(chunk)) + }, + }) + try { + const response = await mf.dispatchFetch('http://localhost/') + expect(await response.text()).toBe('ok') + + const output = () => Buffer.concat(chunks).toString('utf8') + // Logs flush asynchronously from the workerd process. + await expect + .poll(output, { timeout: 10_000 }) + .toContain('E2E-WARN-HEADLINE') + + // Headlines for every level. + expect(output()).toContain('E2E-DEBUG-HEADLINE') + expect(output()).toContain('E2E-ERROR-HEADLINE') + + // The #730 regression: meta must survive, at full depth — workerd's + // own inspect would truncate a depth-5 object passed as a console + // argument, so this also proves the JSON rendering path was taken. + expect(output()).toContain('E2E-META-DEPTH-5') + + // Error instances serialize meaningfully (plain JSON.stringify of an + // Error yields '{}'). + expect(output()).toContain('E2E-UPSTREAM-401') + + // Circular meta must not crash or vanish. + expect(output()).toContain('E2E-CIRCULAR') + expect(output()).toContain('[Circular]') + } finally { + await mf.dispose() + } + }) +})