From 96fb8b054b4d1f4249dabecb81b13f62940c797c Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Fable 5.1)" Date: Thu, 17 Sep 2026 16:58:11 +0200 Subject: [PATCH 1/8] feat(js-lib): commonLoggerContext, a child logger carrying a structured context object Appends one tagged, frozen context object as the last argument of every log call. Wrapping a context logger merges (new keys win) instead of nesting; splitLogContext lets sinks lift it. --- packages/js-lib/src/log/commonLogger.test.ts | 146 ++++++++++++++++++- packages/js-lib/src/log/commonLogger.ts | 91 +++++++++++- 2 files changed, 234 insertions(+), 3 deletions(-) diff --git a/packages/js-lib/src/log/commonLogger.test.ts b/packages/js-lib/src/log/commonLogger.test.ts index 6e55e1185..b36a9d3af 100644 --- a/packages/js-lib/src/log/commonLogger.test.ts +++ b/packages/js-lib/src/log/commonLogger.test.ts @@ -1,11 +1,14 @@ -import { test } from 'vitest' -import type { CommonLogger, CommonLogWithLevelFunction } from './commonLogger.js' +import { expect, test } from 'vitest' +import type { CommonLogger, CommonLogLevel, CommonLogWithLevelFunction } from './commonLogger.js' import { + commonLoggerContext, commonLoggerCreate, commonLoggerNoop, commonLoggerPipe, commonLoggerPrefix, createCommonLoggerAtLevel, + isLogContext, + splitLogContext, } from './commonLogger.js' // This "tests" that `console` is a valid CommonLogger by itself @@ -49,3 +52,142 @@ test('commonLoggerCreate', () => { logger.log('hey') logger.error('hey') }) + +test('commonLoggerContext appends the context as the last argument on every level', () => { + const { logger, calls } = createTestSink() + const contextLogger = commonLoggerContext(logger, { a: 1 }) + + contextLogger.log('m', 2) + + expect(calls).toHaveLength(1) + const [level, args] = calls[0]! + expect(level).toBe('log') + expect(args).toHaveLength(3) + expect(args.slice(0, 2)).toEqual(['m', 2]) + expect(isLogContext(args[2])).toBe(true) + expect(args[2].a).toBe(1) + + calls.length = 0 + contextLogger.debug('m') + contextLogger.warn('m') + contextLogger.error('m') + expect(calls.map(([level]) => level)).toEqual(['debug', 'warn', 'error']) + for (const [, args] of calls) { + expect(args).toHaveLength(2) + expect(isLogContext(args[1])).toBe(true) + } +}) + +test('log context tag is hidden', () => { + const { logger } = createTestSink() + const { context } = commonLoggerContext(logger, { a: 1 }) + + expect(Object.keys(context)).toEqual(['a']) + expect(JSON.stringify(context)).toBe('{"a":1}') + expect(isLogContext({ a: 1 })).toBe(false) + expect(isLogContext(null)).toBe(false) + expect(isLogContext(undefined)).toBe(false) + expect(isLogContext('a')).toBe(false) +}) + +test('child merges the context, child keys win', () => { + const { logger, calls } = createTestSink() + const parent = commonLoggerContext(logger, { a: 1 }) + const child = parent.child({ b: 2, a: 9 }) + + expect({ ...child.context }).toEqual({ a: 9, b: 2 }) + expect({ ...child.child({ c: 3 }).context }).toEqual({ a: 9, b: 2, c: 3 }) + + parent.log('m') + expect({ ...calls[0]![1][1] }).toEqual({ a: 1 }) +}) + +test('wrapping a context logger merges instead of nesting', () => { + const { logger, calls } = createTestSink() + const contextLogger = commonLoggerContext(commonLoggerContext(logger, { a: 1 }), { c: 3 }) + + contextLogger.log('m') + + const [, args] = calls[0]! + expect(args).toHaveLength(2) + expect(isLogContext(args[1])).toBe(true) + expect({ ...args[1] }).toEqual({ a: 1, c: 3 }) +}) + +test('empty context appends nothing', () => { + const { logger, calls } = createTestSink() + + commonLoggerContext(logger, {}).log('m') + + expect(calls[0]![1]).toEqual(['m']) +}) + +test('context is frozen', () => { + const { logger } = createTestSink() + const { context } = commonLoggerContext(logger, { a: 1 }) + + expect(() => { + context['a'] = 2 + }).toThrow('Cannot assign to read only property') + expect(context['a']).toBe(1) +}) + +test('commonLoggerContext composes with commonLoggerPrefix', () => { + const { logger, calls } = createTestSink() + + commonLoggerContext(commonLoggerPrefix(logger, '[p]'), { a: 1 }).log('m') + + const [, args] = calls[0]! + expect(args.slice(0, 2)).toEqual(['[p]', 'm']) + expect(isLogContext(args[2])).toBe(true) +}) + +test('commonLoggerContext on console', () => { + const logger = commonLoggerContext(console, { a: 1 }) + logger.debug('hey') + logger.log('hey') + logger.error('hey') +}) + +test('splitLogContext without a context', () => { + const args = ['m', { a: 1 }] + + const r = splitLogContext(args) + + expect(r.context).toBeUndefined() + expect(r.args).toEqual(['m', { a: 1 }]) + expect(r.args).not.toBe(args) + expect(args).toEqual(['m', { a: 1 }]) +}) + +test('splitLogContext separates the context', () => { + const { logger, calls } = createTestSink() + commonLoggerContext(logger, { a: 1 }).log('m', 2) + const args = calls[0]![1] + + const r = splitLogContext(args) + + expect(r.args).toEqual(['m', 2]) + expect({ ...r.context }).toEqual({ a: 1 }) + expect(args).toHaveLength(3) +}) + +test('splitLogContext merges multiple contexts, later wins', () => { + const { logger, calls } = createTestSink() + const ctx1 = commonLoggerContext(logger, { a: 1, b: 1 }).context + const ctx2 = commonLoggerContext(logger, { b: 2 }).context + + const r = splitLogContext([ctx1, 'm', ctx2]) + + expect(r.args).toEqual(['m']) + expect({ ...r.context }).toEqual({ a: 1, b: 2 }) + expect(calls).toHaveLength(0) +}) + +function createTestSink(): { logger: CommonLogger; calls: [CommonLogLevel, any[]][] } { + const calls: [CommonLogLevel, any[]][] = [] + return { + logger: commonLoggerCreate((level, args) => calls.push([level, args])), + calls, + } +} diff --git a/packages/js-lib/src/log/commonLogger.ts b/packages/js-lib/src/log/commonLogger.ts index a07872558..3c62a5285 100644 --- a/packages/js-lib/src/log/commonLogger.ts +++ b/packages/js-lib/src/log/commonLogger.ts @@ -1,4 +1,4 @@ -import type { MutateOptions } from '../types.js' +import type { AnyObject, MutateOptions } from '../types.js' // copy-pasted to avoid weird circular dependency const _noop = (..._args: any[]): undefined => undefined @@ -136,3 +136,92 @@ export function commonLoggerCreate(fn: CommonLogWithLevelFunction): CommonLogger error: (...args) => fn('error', args), } } + +// tagged as a non-enumerable own property, so the tag never shows up +// in Object.keys / JSON.stringify / inspect output. +// Symbol.for, so it survives duplicate copies of js-lib in node_modules. +const LOG_CONTEXT: unique symbol = Symbol.for('@naturalcycles/js-lib/logContext') +const LOGGER_BASE: unique symbol = Symbol.for('@naturalcycles/js-lib/logContextBase') + +/** + * Object of structured log fields, tagged so that log sinks can recognize it. + * + * @experimental + */ +export type LogContext = AnyObject & { readonly [LOG_CONTEXT]: true } + +/** + * CommonLogger that carries a context object. + * + * @experimental + */ +export interface CommonLoggerWithContext extends CommonLogger { + readonly context: AnyObject + child: (context: AnyObject) => CommonLoggerWithContext +} + +/** + * Creates a "child" CommonLogger that appends the given context as the LAST argument + * of every log call, tagged as a LogContext. + * + * Sinks that know the tag (via splitLogContext) can lift its fields into a structured + * log entry, `console` simply prints the object after the message. + * + * Contexts merge instead of nesting: wrapping a context logger (or calling `child`) + * produces a logger that still appends exactly one context object, where the new keys win. + * + * @experimental + */ +export function commonLoggerContext( + logger: CommonLogger, + context: AnyObject, +): CommonLoggerWithContext { + const parent = (logger as any)[LOGGER_BASE] as CommonLogger | undefined + const base = parent ?? logger + const ctx: AnyObject = parent + ? { ...(logger as CommonLoggerWithContext).context, ...context } + : { ...context } + Object.defineProperty(ctx, LOG_CONTEXT, { value: true }) + Object.freeze(ctx) + + const empty = !Object.keys(ctx).length + + const contextLogger: CommonLoggerWithContext = { + context: ctx, + child: c => commonLoggerContext(base, { ...ctx, ...c }), + debug: empty ? base.debug.bind(base) : (...args) => base.debug(...args, ctx), + log: empty ? base.log.bind(base) : (...args) => base.log(...args, ctx), + warn: empty ? base.warn.bind(base) : (...args) => base.warn(...args, ctx), + error: empty ? base.error.bind(base) : (...args) => base.error(...args, ctx), + } + Object.defineProperty(contextLogger, LOGGER_BASE, { value: base }) + return contextLogger +} + +/** + * Separates the LogContext arguments (as appended by commonLoggerContext) from the rest. + * Multiple contexts are merged, later ones win. Input args are not mutated. + * + * @experimental + */ +export function splitLogContext(args: any[]): { context: AnyObject | undefined; args: any[] } { + let context: AnyObject | undefined + const rest: any[] = [] + for (const arg of args) { + if (isLogContext(arg)) { + context = context ? { ...context, ...arg } : arg + } else { + rest.push(arg) + } + } + return { context, args: rest } +} + +/** + * Returns true if the value is a LogContext, as created by commonLoggerContext. + * + * @experimental + */ +export function isLogContext(value: unknown): value is LogContext { + return typeof value === 'object' && value !== null && (value as any)[LOG_CONTEXT] === true +} From 718a92e013a38c93ec81b5ef08e604371d4399b9 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Fable 5.1)" Date: Thu, 17 Sep 2026 16:58:14 +0200 Subject: [PATCH 2/8] feat(backend-lib): lift log context into GCP structured log fields writeGCPStructuredLog spreads the context as top-level jsonPayload fields, meta last so severity and trace win. devLogger prints the context dimmed after the message. --- .../src/server/logMiddleware.test.ts | 54 +++++++++++++++++++ .../backend-lib/src/server/logMiddleware.ts | 10 +++- 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 packages/backend-lib/src/server/logMiddleware.test.ts diff --git a/packages/backend-lib/src/server/logMiddleware.test.ts b/packages/backend-lib/src/server/logMiddleware.test.ts new file mode 100644 index 000000000..626232870 --- /dev/null +++ b/packages/backend-lib/src/server/logMiddleware.test.ts @@ -0,0 +1,54 @@ +import { commonLoggerContext } from '@naturalcycles/js-lib/log' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { devLogger, gcpStructuredLogger } from './logMiddleware.js' + +let calls: any[][] + +beforeEach(() => { + calls = [] + vi.spyOn(console, 'log').mockImplementation((...args: any[]) => { + calls.push(args) + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +test('gcpStructuredLogger lifts the log context into the structured entry', () => { + commonLoggerContext(gcpStructuredLogger, { accountId: 'a1' }).warn('hello', { n: 1 }) + + expect(calls).toHaveLength(1) + expect(calls[0]).toHaveLength(1) + expect(JSON.parse(calls[0]![0])).toEqual({ + accountId: 'a1', + message: 'hello { n: 1 }', + severity: 'WARNING', + }) +}) + +test('log context cannot override meta', () => { + commonLoggerContext(gcpStructuredLogger, { severity: 'nope' }).error('hello') + + expect(JSON.parse(calls[0]![0])).toEqual({ + message: 'hello', + severity: 'ERROR', + }) +}) + +test('gcpStructuredLogger without a log context', () => { + gcpStructuredLogger.log('plain') + + expect(JSON.parse(calls[0]![0])).toEqual({ + message: 'plain', + }) +}) + +test('devLogger prints the log context', () => { + commonLoggerContext(devLogger, { accountId: 'a1' }).log('hello') + + expect(calls).toHaveLength(1) + const output = calls[0]![0] as string + expect(output).toContain('hello') + expect(output).toContain('accountId') +}) diff --git a/packages/backend-lib/src/server/logMiddleware.ts b/packages/backend-lib/src/server/logMiddleware.ts index 4a58efe72..2fcec1b98 100644 --- a/packages/backend-lib/src/server/logMiddleware.ts +++ b/packages/backend-lib/src/server/logMiddleware.ts @@ -1,4 +1,5 @@ import { inspect } from 'node:util' +import { splitLogContext } from '@naturalcycles/js-lib/log' import type { CommonLogger } from '@naturalcycles/js-lib/log' import { _objectAssign } from '@naturalcycles/js-lib/types' import type { AnyObject } from '@naturalcycles/js-lib/types' @@ -50,20 +51,25 @@ export const ciLogger: CommonLogger = { // Documented here: https://cloud.google.com/logging/docs/structured-logging // Cloud Run logging: https://cloud.google.com/run/docs/logging function writeGCPStructuredLog(meta: AnyObject, args: any[]): void { + const { context, args: rest } = splitLogContext(args) console.log( JSON.stringify({ - message: args.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '), + ...context, + message: rest.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '), + // meta is spread last, so that the context can never clobber severity/labels/trace keys ...meta, }), ) } function logToDev(requestId: string | null, args: any[]): void { + const { context, args: rest } = splitLogContext(args) // Run on local machine console.log( [ requestId ? [dimGrey(`[${requestId}]`)] : [], - ...args.map(a => _inspect(a, { includeErrorStack: true, colors: true })), + ...rest.map(a => _inspect(a, { includeErrorStack: true, colors: true })), + ...(context ? [dimGrey(_inspect(context, { colors: false }))] : []), ].join(' '), ) } From 643c299ed059c57aaa08e3b6be7764c30ebe9615 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Fable 5.1)" Date: Fri, 18 Sep 2026 09:11:21 +0200 Subject: [PATCH 3/8] feat(js-lib): commonLoggerCreate takes a context and emits one pino-style entry per call Replaces commonLoggerContext/splitLogContext. A context as second argument makes the logger contextful: plain-object args are merged into the entry, the first Error becomes err, the rest is joined into msg. child(context) merges. The sink may be a CommonLogger such as console. --- packages/js-lib/src/log/commonLogger.test.ts | 161 ++++++++----------- packages/js-lib/src/log/commonLogger.ts | 129 ++++++--------- 2 files changed, 116 insertions(+), 174 deletions(-) diff --git a/packages/js-lib/src/log/commonLogger.test.ts b/packages/js-lib/src/log/commonLogger.test.ts index b36a9d3af..0f6b50dd2 100644 --- a/packages/js-lib/src/log/commonLogger.test.ts +++ b/packages/js-lib/src/log/commonLogger.test.ts @@ -1,14 +1,12 @@ import { expect, test } from 'vitest' +import type { AnyObject } from '../types.js' import type { CommonLogger, CommonLogLevel, CommonLogWithLevelFunction } from './commonLogger.js' import { - commonLoggerContext, commonLoggerCreate, commonLoggerNoop, commonLoggerPipe, commonLoggerPrefix, createCommonLoggerAtLevel, - isLogContext, - splitLogContext, } from './commonLogger.js' // This "tests" that `console` is a valid CommonLogger by itself @@ -53,135 +51,114 @@ test('commonLoggerCreate', () => { logger.error('hey') }) -test('commonLoggerContext appends the context as the last argument on every level', () => { +test('commonLoggerCreate accepts a CommonLogger as a sink', () => { const { logger, calls } = createTestSink() - const contextLogger = commonLoggerContext(logger, { a: 1 }) - - contextLogger.log('m', 2) - - expect(calls).toHaveLength(1) - const [level, args] = calls[0]! - expect(level).toBe('log') - expect(args).toHaveLength(3) - expect(args.slice(0, 2)).toEqual(['m', 2]) - expect(isLogContext(args[2])).toBe(true) - expect(args[2].a).toBe(1) - - calls.length = 0 - contextLogger.debug('m') - contextLogger.warn('m') - contextLogger.error('m') - expect(calls.map(([level]) => level)).toEqual(['debug', 'warn', 'error']) - for (const [, args] of calls) { - expect(args).toHaveLength(2) - expect(isLogContext(args[1])).toBe(true) - } -}) -test('log context tag is hidden', () => { - const { logger } = createTestSink() - const { context } = commonLoggerContext(logger, { a: 1 }) + commonLoggerCreate(logger).log('a', 1) - expect(Object.keys(context)).toEqual(['a']) - expect(JSON.stringify(context)).toBe('{"a":1}') - expect(isLogContext({ a: 1 })).toBe(false) - expect(isLogContext(null)).toBe(false) - expect(isLogContext(undefined)).toBe(false) - expect(isLogContext('a')).toBe(false) + expect(calls).toStrictEqual([['log', ['a', 1]]]) }) -test('child merges the context, child keys win', () => { +test('a context makes every call emit a single object', () => { const { logger, calls } = createTestSink() - const parent = commonLoggerContext(logger, { a: 1 }) - const child = parent.child({ b: 2, a: 9 }) + const contextLogger = commonLoggerCreate(logger, { a: 1 }) - expect({ ...child.context }).toEqual({ a: 9, b: 2 }) - expect({ ...child.child({ c: 3 }).context }).toEqual({ a: 9, b: 2, c: 3 }) + contextLogger.debug('hey') + contextLogger.log('hey') + contextLogger.warn('hey') + contextLogger.error('hey') - parent.log('m') - expect({ ...calls[0]![1][1] }).toEqual({ a: 1 }) + expect(calls).toStrictEqual([ + ['debug', [{ a: 1, msg: 'hey' }]], + ['log', [{ a: 1, msg: 'hey' }]], + ['warn', [{ a: 1, msg: 'hey' }]], + ['error', [{ a: 1, msg: 'hey' }]], + ]) }) -test('wrapping a context logger merges instead of nesting', () => { +test('child merges the context, child keys win', () => { const { logger, calls } = createTestSink() - const contextLogger = commonLoggerContext(commonLoggerContext(logger, { a: 1 }), { c: 3 }) + const parent = commonLoggerCreate(logger, { a: 1 }) + const child = parent.child({ b: 2, a: 9 }) - contextLogger.log('m') + child.debug('foo') + parent.debug('foo') + child.child({ c: 3 }).debug('foo') - const [, args] = calls[0]! - expect(args).toHaveLength(2) - expect(isLogContext(args[1])).toBe(true) - expect({ ...args[1] }).toEqual({ a: 1, c: 3 }) + expect(calls).toStrictEqual([ + ['debug', [{ a: 9, b: 2, msg: 'foo' }]], + ['debug', [{ a: 1, msg: 'foo' }]], + ['debug', [{ a: 9, b: 2, c: 3, msg: 'foo' }]], + ]) }) -test('empty context appends nothing', () => { +test('a plain object argument is merged in, without a msg', () => { const { logger, calls } = createTestSink() - commonLoggerContext(logger, {}).log('m') + commonLoggerCreate(logger, { a: 1 }).debug({ structured: 'data' }) - expect(calls[0]![1]).toEqual(['m']) + expect(calls[0]![1]).toStrictEqual([{ a: 1, structured: 'data' }]) }) -test('context is frozen', () => { - const { logger } = createTestSink() - const { context } = commonLoggerContext(logger, { a: 1 }) +test('non-object arguments are joined into msg', () => { + const { logger, calls } = createTestSink() + const contextLogger = commonLoggerCreate(logger, { a: 1 }) + + contextLogger.warn('hello', { n: 1 }, 2) + contextLogger.log([1, 2]) - expect(() => { - context['a'] = 2 - }).toThrow('Cannot assign to read only property') - expect(context['a']).toBe(1) + expect(calls[0]![1]).toStrictEqual([{ a: 1, n: 1, msg: 'hello 2' }]) + expect(calls[1]![1]).toStrictEqual([{ a: 1, msg: '1,2' }]) }) -test('commonLoggerContext composes with commonLoggerPrefix', () => { +test('an argument wins over the context', () => { const { logger, calls } = createTestSink() - commonLoggerContext(commonLoggerPrefix(logger, '[p]'), { a: 1 }).log('m') + commonLoggerCreate(logger, { a: 1 }).log({ a: 9 }) - const [, args] = calls[0]! - expect(args.slice(0, 2)).toEqual(['[p]', 'm']) - expect(isLogContext(args[2])).toBe(true) + expect(calls[0]![1]).toStrictEqual([{ a: 9 }]) }) -test('commonLoggerContext on console', () => { - const logger = commonLoggerContext(console, { a: 1 }) - logger.debug('hey') - logger.log('hey') - logger.error('hey') -}) - -test('splitLogContext without a context', () => { - const args = ['m', { a: 1 }] +test('the first Error argument becomes err', () => { + const { logger, calls } = createTestSink() + const contextLogger = commonLoggerCreate(logger, { a: 1 }) + const err = new Error('boom') - const r = splitLogContext(args) + contextLogger.error(err) + contextLogger.error('failed', err) + contextLogger.error(err, new Error('second')) - expect(r.context).toBeUndefined() - expect(r.args).toEqual(['m', { a: 1 }]) - expect(r.args).not.toBe(args) - expect(args).toEqual(['m', { a: 1 }]) + expect(calls[0]![1]).toStrictEqual([{ a: 1, err }]) + expect(calls[0]![1][0].err).toBe(err) + expect(calls[1]![1]).toStrictEqual([{ a: 1, err, msg: 'failed' }]) + expect(calls[2]![1]).toStrictEqual([{ a: 1, err, msg: 'Error: second' }]) + expect(calls[2]![1][0].err).toBe(err) }) -test('splitLogContext separates the context', () => { +test('the context is copied on creation', () => { const { logger, calls } = createTestSink() - commonLoggerContext(logger, { a: 1 }).log('m', 2) - const args = calls[0]![1] + const context: AnyObject = { a: 1 } + const contextLogger = commonLoggerCreate(logger, context) + + context['b'] = 2 + contextLogger.log('m') - const r = splitLogContext(args) + expect(calls[0]![1]).toStrictEqual([{ a: 1, msg: 'm' }]) +}) - expect(r.args).toEqual(['m', 2]) - expect({ ...r.context }).toEqual({ a: 1 }) - expect(args).toHaveLength(3) +test('commonLoggerCreate with a context on console', () => { + const logger = commonLoggerCreate(console, { a: 1 }) + logger.debug('hey') + logger.log('hey') + logger.error('hey') }) -test('splitLogContext merges multiple contexts, later wins', () => { +test('an empty context still wraps the arguments', () => { const { logger, calls } = createTestSink() - const ctx1 = commonLoggerContext(logger, { a: 1, b: 1 }).context - const ctx2 = commonLoggerContext(logger, { b: 2 }).context - const r = splitLogContext([ctx1, 'm', ctx2]) + commonLoggerCreate(logger, {}).log('x') - expect(r.args).toEqual(['m']) - expect({ ...r.context }).toEqual({ a: 1, b: 2 }) - expect(calls).toHaveLength(0) + expect(calls[0]![1]).toStrictEqual([{ msg: 'x' }]) }) function createTestSink(): { logger: CommonLogger; calls: [CommonLogLevel, any[]][] } { diff --git a/packages/js-lib/src/log/commonLogger.ts b/packages/js-lib/src/log/commonLogger.ts index 3c62a5285..950a5780d 100644 --- a/packages/js-lib/src/log/commonLogger.ts +++ b/packages/js-lib/src/log/commonLogger.ts @@ -125,103 +125,68 @@ export function commonLoggerPrefix(logger: CommonLogger, ...prefixes: any[]): Co } } -/** - * Creates a CommonLogger from a single function that takes `level` and `args`. - */ -export function commonLoggerCreate(fn: CommonLogWithLevelFunction): CommonLogger { - return { - debug: (...args) => fn('debug', args), - log: (...args) => fn('log', args), - warn: (...args) => fn('warn', args), - error: (...args) => fn('error', args), - } -} - -// tagged as a non-enumerable own property, so the tag never shows up -// in Object.keys / JSON.stringify / inspect output. -// Symbol.for, so it survives duplicate copies of js-lib in node_modules. -const LOG_CONTEXT: unique symbol = Symbol.for('@naturalcycles/js-lib/logContext') -const LOGGER_BASE: unique symbol = Symbol.for('@naturalcycles/js-lib/logContextBase') +export type CommonLogSink = CommonLogger | CommonLogWithLevelFunction -/** - * Object of structured log fields, tagged so that log sinks can recognize it. - * - * @experimental - */ -export type LogContext = AnyObject & { readonly [LOG_CONTEXT]: true } - -/** - * CommonLogger that carries a context object. - * - * @experimental - */ export interface CommonLoggerWithContext extends CommonLogger { - readonly context: AnyObject child: (context: AnyObject) => CommonLoggerWithContext } /** - * Creates a "child" CommonLogger that appends the given context as the LAST argument - * of every log call, tagged as a LogContext. - * - * Sinks that know the tag (via splitLogContext) can lift its fields into a structured - * log entry, `console` simply prints the object after the message. - * - * Contexts merge instead of nesting: wrapping a context logger (or calling `child`) - * produces a logger that still appends exactly one context object, where the new keys win. + * Creates a CommonLogger from a single function that takes `level` and `args`. * - * @experimental + * With a context, every call emits one object: plain-object args merged in, first Error as `err`, + * the rest joined into `msg`. */ -export function commonLoggerContext( - logger: CommonLogger, - context: AnyObject, -): CommonLoggerWithContext { - const parent = (logger as any)[LOGGER_BASE] as CommonLogger | undefined - const base = parent ?? logger - const ctx: AnyObject = parent - ? { ...(logger as CommonLoggerWithContext).context, ...context } - : { ...context } - Object.defineProperty(ctx, LOG_CONTEXT, { value: true }) - Object.freeze(ctx) - - const empty = !Object.keys(ctx).length - - const contextLogger: CommonLoggerWithContext = { - context: ctx, - child: c => commonLoggerContext(base, { ...ctx, ...c }), - debug: empty ? base.debug.bind(base) : (...args) => base.debug(...args, ctx), - log: empty ? base.log.bind(base) : (...args) => base.log(...args, ctx), - warn: empty ? base.warn.bind(base) : (...args) => base.warn(...args, ctx), - error: empty ? base.error.bind(base) : (...args) => base.error(...args, ctx), +export function commonLoggerCreate(sink: CommonLogSink): CommonLogger +export function commonLoggerCreate(sink: CommonLogSink, context: AnyObject): CommonLoggerWithContext +export function commonLoggerCreate(sink: CommonLogSink, context?: AnyObject): CommonLogger { + const fn: CommonLogWithLevelFunction = + typeof sink === 'function' ? sink : (level, args) => sink[level](...args) + + if (!context) { + return { + debug: (...args) => fn('debug', args), + log: (...args) => fn('log', args), + warn: (...args) => fn('warn', args), + error: (...args) => fn('error', args), + } } - Object.defineProperty(contextLogger, LOGGER_BASE, { value: base }) - return contextLogger + + const ctx: AnyObject = { ...context } + + const logger: CommonLoggerWithContext = { + debug: (...args) => fn('debug', [toLogEntry(ctx, args)]), + log: (...args) => fn('log', [toLogEntry(ctx, args)]), + warn: (...args) => fn('warn', [toLogEntry(ctx, args)]), + error: (...args) => fn('error', [toLogEntry(ctx, args)]), + child: c => commonLoggerCreate(fn, { ...ctx, ...c }), + } + return logger } -/** - * Separates the LogContext arguments (as appended by commonLoggerContext) from the rest. - * Multiple contexts are merged, later ones win. Input args are not mutated. - * - * @experimental - */ -export function splitLogContext(args: any[]): { context: AnyObject | undefined; args: any[] } { - let context: AnyObject | undefined - const rest: any[] = [] +function toLogEntry(context: AnyObject, args: any[]): AnyObject { + const entry: AnyObject = { ...context } + let err: Error | undefined + let msg: string | undefined + for (const arg of args) { - if (isLogContext(arg)) { - context = context ? { ...context, ...arg } : arg + if (isPlainObject(arg)) { + Object.assign(entry, arg) + } else if (!err && arg instanceof Error) { + err = arg } else { - rest.push(arg) + const s = String(arg) + msg = msg === undefined ? s : `${msg} ${s}` } } - return { context, args: rest } + + if (err) entry['err'] = err + if (msg !== undefined) entry['msg'] = msg + return entry } -/** - * Returns true if the value is a LogContext, as created by commonLoggerContext. - * - * @experimental - */ -export function isLogContext(value: unknown): value is LogContext { - return typeof value === 'object' && value !== null && (value as any)[LOG_CONTEXT] === true +function isPlainObject(v: any): boolean { + if (typeof v !== 'object' || v === null) return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null } From 44445ee8461c3e55a60c52cd5f68f5eb09aacec1 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Fable 5.1)" Date: Fri, 18 Sep 2026 09:11:23 +0200 Subject: [PATCH 4/8] feat(backend-lib): GCP sink writes a single plain-object entry as structured fields msg becomes message, an Error in err is serialized and its stack kept in message, meta is spread last. devLogger prints msg, err and then the remaining fields dimmed. --- .../src/server/logMiddleware.test.ts | 48 ++++++++++++++----- .../backend-lib/src/server/logMiddleware.ts | 41 ++++++++++++---- 2 files changed, 68 insertions(+), 21 deletions(-) diff --git a/packages/backend-lib/src/server/logMiddleware.test.ts b/packages/backend-lib/src/server/logMiddleware.test.ts index 626232870..ff10c5541 100644 --- a/packages/backend-lib/src/server/logMiddleware.test.ts +++ b/packages/backend-lib/src/server/logMiddleware.test.ts @@ -1,4 +1,4 @@ -import { commonLoggerContext } from '@naturalcycles/js-lib/log' +import { commonLoggerCreate } from '@naturalcycles/js-lib/log' import { afterEach, beforeEach, expect, test, vi } from 'vitest' import { devLogger, gcpStructuredLogger } from './logMiddleware.js' @@ -15,40 +15,62 @@ afterEach(() => { vi.restoreAllMocks() }) -test('gcpStructuredLogger lifts the log context into the structured entry', () => { - commonLoggerContext(gcpStructuredLogger, { accountId: 'a1' }).warn('hello', { n: 1 }) +test('gcpStructuredLogger writes the log entry as structured fields', () => { + commonLoggerCreate(gcpStructuredLogger, { accountId: 'a1' }).warn('hello', { n: 1 }) expect(calls).toHaveLength(1) expect(calls[0]).toHaveLength(1) - expect(JSON.parse(calls[0]![0])).toEqual({ + expect(JSON.parse(calls[0]![0])).toStrictEqual({ accountId: 'a1', - message: 'hello { n: 1 }', + n: 1, + message: 'hello', severity: 'WARNING', }) }) -test('log context cannot override meta', () => { - commonLoggerContext(gcpStructuredLogger, { severity: 'nope' }).error('hello') +test('the log entry cannot override meta', () => { + commonLoggerCreate(gcpStructuredLogger, { severity: 'nope' }).error('x') - expect(JSON.parse(calls[0]![0])).toEqual({ - message: 'hello', + expect(JSON.parse(calls[0]![0])).toStrictEqual({ + message: 'x', severity: 'ERROR', }) }) -test('gcpStructuredLogger without a log context', () => { +test('gcpStructuredLogger with a single string argument', () => { gcpStructuredLogger.log('plain') - expect(JSON.parse(calls[0]![0])).toEqual({ + expect(JSON.parse(calls[0]![0])).toStrictEqual({ message: 'plain', }) }) -test('devLogger prints the log context', () => { - commonLoggerContext(devLogger, { accountId: 'a1' }).log('hello') +test('gcpStructuredLogger with multiple arguments', () => { + gcpStructuredLogger.log('a', { b: 1 }) + + expect(JSON.parse(calls[0]![0])).toStrictEqual({ + message: 'a { b: 1 }', + }) +}) + +test('gcpStructuredLogger keeps the error stack in message', () => { + commonLoggerCreate(gcpStructuredLogger, {}).error('failed', new Error('kaboom')) + + const entry = JSON.parse(calls[0]![0]) + expect(entry.message).toMatch(/^failed\n/) + expect(entry.message).toContain('kaboom') + expect(entry.message).toContain(' at ') + expect(entry.err).toEqual( + expect.objectContaining({ name: 'Error', message: 'kaboom', stack: expect.any(String) }), + ) +}) + +test('devLogger prints the log entry', () => { + commonLoggerCreate(devLogger, { accountId: 'a1' }).log('hello', { n: 1 }) expect(calls).toHaveLength(1) const output = calls[0]![0] as string expect(output).toContain('hello') expect(output).toContain('accountId') + expect(output).toContain('n') }) diff --git a/packages/backend-lib/src/server/logMiddleware.ts b/packages/backend-lib/src/server/logMiddleware.ts index 2fcec1b98..2dfb13079 100644 --- a/packages/backend-lib/src/server/logMiddleware.ts +++ b/packages/backend-lib/src/server/logMiddleware.ts @@ -1,5 +1,5 @@ import { inspect } from 'node:util' -import { splitLogContext } from '@naturalcycles/js-lib/log' +import { _anyToErrorObject } from '@naturalcycles/js-lib/error' import type { CommonLogger } from '@naturalcycles/js-lib/log' import { _objectAssign } from '@naturalcycles/js-lib/types' import type { AnyObject } from '@naturalcycles/js-lib/types' @@ -51,25 +51,44 @@ export const ciLogger: CommonLogger = { // Documented here: https://cloud.google.com/logging/docs/structured-logging // Cloud Run logging: https://cloud.google.com/run/docs/logging function writeGCPStructuredLog(meta: AnyObject, args: any[]): void { - const { context, args: rest } = splitLogContext(args) + if (args.length === 1 && isPlainObject(args[0])) { + const { msg, err, ...rest } = args[0] + let message = msg + let errObject = err + if (err instanceof Error) { + errObject = _anyToErrorObject(err) + const errStack = inspect(err) + message = msg ? `${msg}\n${errStack}` : errStack + } + console.log(JSON.stringify({ ...rest, message, err: errObject, ...meta })) + return + } + console.log( JSON.stringify({ - ...context, - message: rest.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '), - // meta is spread last, so that the context can never clobber severity/labels/trace keys + message: args.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '), ...meta, }), ) } function logToDev(requestId: string | null, args: any[]): void { - const { context, args: rest } = splitLogContext(args) // Run on local machine + if (args.length === 1 && isPlainObject(args[0])) { + const { msg, err, ...rest } = args[0] + const parts: string[] = [] + if (requestId) parts.push(dimGrey(`[${requestId}]`)) + if (msg !== undefined) parts.push(String(msg)) + if (err !== undefined) parts.push(_inspect(err, { includeErrorStack: true, colors: true })) + if (Object.keys(rest).length) parts.push(dimGrey(_inspect(rest, { colors: false }))) + console.log(parts.join(' ')) + return + } + console.log( [ requestId ? [dimGrey(`[${requestId}]`)] : [], - ...rest.map(a => _inspect(a, { includeErrorStack: true, colors: true })), - ...(context ? [dimGrey(_inspect(context, { colors: false }))] : []), + ...args.map(a => _inspect(a, { includeErrorStack: true, colors: true })), ].join(' '), ) } @@ -141,3 +160,9 @@ export function logMiddleware(): BackendRequestHandler { next() } } + +function isPlainObject(v: any): boolean { + if (typeof v !== 'object' || v === null) return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} From cfa9f93e446e3fc7cc10dbf0ffa39cdace34f68b Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Fable 5.1)" Date: Fri, 18 Sep 2026 09:33:32 +0200 Subject: [PATCH 5/8] feat(js-lib): _isPlainObject type guard and PlainObject type Replaces the two private isPlainObject copies in commonLogger and the backend-lib GCP sink. --- .../backend-lib/src/server/logMiddleware.ts | 11 +++----- packages/js-lib/src/is.util.test.ts | 25 +++++++++++++++++++ packages/js-lib/src/is.util.ts | 11 +++++++- packages/js-lib/src/log/commonLogger.ts | 9 ++----- packages/js-lib/src/types.ts | 5 ++++ 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/backend-lib/src/server/logMiddleware.ts b/packages/backend-lib/src/server/logMiddleware.ts index 2dfb13079..4e22d697c 100644 --- a/packages/backend-lib/src/server/logMiddleware.ts +++ b/packages/backend-lib/src/server/logMiddleware.ts @@ -1,4 +1,5 @@ import { inspect } from 'node:util' +import { _isPlainObject } from '@naturalcycles/js-lib' import { _anyToErrorObject } from '@naturalcycles/js-lib/error' import type { CommonLogger } from '@naturalcycles/js-lib/log' import { _objectAssign } from '@naturalcycles/js-lib/types' @@ -51,7 +52,7 @@ export const ciLogger: CommonLogger = { // Documented here: https://cloud.google.com/logging/docs/structured-logging // Cloud Run logging: https://cloud.google.com/run/docs/logging function writeGCPStructuredLog(meta: AnyObject, args: any[]): void { - if (args.length === 1 && isPlainObject(args[0])) { + if (args.length === 1 && _isPlainObject(args[0])) { const { msg, err, ...rest } = args[0] let message = msg let errObject = err @@ -74,7 +75,7 @@ function writeGCPStructuredLog(meta: AnyObject, args: any[]): void { function logToDev(requestId: string | null, args: any[]): void { // Run on local machine - if (args.length === 1 && isPlainObject(args[0])) { + if (args.length === 1 && _isPlainObject(args[0])) { const { msg, err, ...rest } = args[0] const parts: string[] = [] if (requestId) parts.push(dimGrey(`[${requestId}]`)) @@ -160,9 +161,3 @@ export function logMiddleware(): BackendRequestHandler { next() } } - -function isPlainObject(v: any): boolean { - if (typeof v !== 'object' || v === null) return false - const proto = Object.getPrototypeOf(v) - return proto === Object.prototype || proto === null -} diff --git a/packages/js-lib/src/is.util.test.ts b/packages/js-lib/src/is.util.test.ts index 301e839b0..5a9473f67 100644 --- a/packages/js-lib/src/is.util.test.ts +++ b/packages/js-lib/src/is.util.test.ts @@ -8,6 +8,7 @@ import { _isNotEmptyObject, _isNull, _isObject, + _isPlainObject, _isPrimitive, _isTruthy, } from './is.util.js' @@ -73,6 +74,30 @@ test('_isObject', () => { expect(_isObject(new Obj())).toBe(true) }) +test('_isPlainObject', () => { + expect(_isPlainObject({})).toBe(true) + expect(_isPlainObject({ a: 1 })).toBe(true) + expect(_isPlainObject(Object.create(null))).toBe(true) + + class Obj {} + + const a: any[] = [ + [], + null, + undefined, + 's', + 1, + new Error('e'), + new Date(), + new Obj(), + /re/, + () => {}, + ] + for (const v of a) { + expect(_isPlainObject(v)).toBe(false) + } +}) + test('_isEmptyObject', () => { const a: any[] = [{}, { a: 'b' }] diff --git a/packages/js-lib/src/is.util.ts b/packages/js-lib/src/is.util.ts index 99d07c9b6..754c469b4 100644 --- a/packages/js-lib/src/is.util.ts +++ b/packages/js-lib/src/is.util.ts @@ -1,4 +1,4 @@ -import type { AnyObject, FalsyValue, NullishValue, Primitive } from './types.js' +import type { AnyObject, FalsyValue, NullishValue, PlainObject, Primitive } from './types.js' type Nullish = T extends NullishValue ? T : never type Truthy = T extends FalsyValue ? never : T @@ -32,6 +32,15 @@ export function _isObject(obj: any): obj is AnyObject { return (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) || false } +/** + * Returns true if item is an object literal, e.g not an Array, Error, RegExp or class instance. + */ +export function _isPlainObject(v: any): v is PlainObject { + if (typeof v !== 'object' || v === null) return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + export function _isPrimitive(v: any): v is Primitive { return ( v === null || diff --git a/packages/js-lib/src/log/commonLogger.ts b/packages/js-lib/src/log/commonLogger.ts index 950a5780d..40901c110 100644 --- a/packages/js-lib/src/log/commonLogger.ts +++ b/packages/js-lib/src/log/commonLogger.ts @@ -1,3 +1,4 @@ +import { _isPlainObject } from '../is.util.js' import type { AnyObject, MutateOptions } from '../types.js' // copy-pasted to avoid weird circular dependency @@ -170,7 +171,7 @@ function toLogEntry(context: AnyObject, args: any[]): AnyObject { let msg: string | undefined for (const arg of args) { - if (isPlainObject(arg)) { + if (_isPlainObject(arg)) { Object.assign(entry, arg) } else if (!err && arg instanceof Error) { err = arg @@ -184,9 +185,3 @@ function toLogEntry(context: AnyObject, args: any[]): AnyObject { if (msg !== undefined) entry['msg'] = msg return entry } - -function isPlainObject(v: any): boolean { - if (typeof v !== 'object' || v === null) return false - const proto = Object.getPrototypeOf(v) - return proto === Object.prototype || proto === null -} diff --git a/packages/js-lib/src/types.ts b/packages/js-lib/src/types.ts index fd9aa0f5e..e443d9843 100644 --- a/packages/js-lib/src/types.ts +++ b/packages/js-lib/src/types.ts @@ -30,6 +30,11 @@ export interface StringMap { */ export type AnyObject = Record +/** + * Object literal, with a prototype of `Object.prototype` or `null`. What `_isPlainObject` narrows to. + */ +export type PlainObject = Record + export type AnyEnum = NumberEnum export type NumberEnum = Record export type StringEnum = Record From a23f4f7a23bcd0ad996b5a60cb166043d1ba0e04 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Fable 5.1)" Date: Fri, 18 Sep 2026 09:41:04 +0200 Subject: [PATCH 6/8] feat(js-lib): brand PlainObject so only _isPlainObject (or a cast) produces one --- packages/js-lib/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/js-lib/src/types.ts b/packages/js-lib/src/types.ts index e443d9843..2ec865693 100644 --- a/packages/js-lib/src/types.ts +++ b/packages/js-lib/src/types.ts @@ -33,7 +33,7 @@ export type AnyObject = Record /** * Object literal, with a prototype of `Object.prototype` or `null`. What `_isPlainObject` narrows to. */ -export type PlainObject = Record +export type PlainObject = Branded export type AnyEnum = NumberEnum export type NumberEnum = Record From 4eb6a86b2e93f5ae3f5ed5987a5df1ba6cb54763 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Fable 5.1)" Date: Fri, 18 Sep 2026 10:29:12 +0200 Subject: [PATCH 7/8] fix(backend-lib): GCP structured entry is cycle-safe and keeps a message field Claude-Session: https://claude.ai/code/session_0167KuoFQNJXsXrM6mc3R9ux --- .../src/server/logMiddleware.test.ts | 23 +++++++++++++++++++ .../backend-lib/src/server/logMiddleware.ts | 15 ++++++------ packages/js-lib/src/is.util.ts | 2 +- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/backend-lib/src/server/logMiddleware.test.ts b/packages/backend-lib/src/server/logMiddleware.test.ts index ff10c5541..0d3297309 100644 --- a/packages/backend-lib/src/server/logMiddleware.test.ts +++ b/packages/backend-lib/src/server/logMiddleware.test.ts @@ -1,4 +1,5 @@ import { commonLoggerCreate } from '@naturalcycles/js-lib/log' +import type { AnyObject } from '@naturalcycles/js-lib/types' import { afterEach, beforeEach, expect, test, vi } from 'vitest' import { devLogger, gcpStructuredLogger } from './logMiddleware.js' @@ -65,6 +66,28 @@ test('gcpStructuredLogger keeps the error stack in message', () => { ) }) +test('gcpStructuredLogger keeps a message field, msg wins over it', () => { + const logger = commonLoggerCreate(gcpStructuredLogger, {}) + + logger.log({ message: 'x' }) + logger.log({ message: 'x' }, 'y') + + expect(JSON.parse(calls[0]![0])).toStrictEqual({ message: 'x' }) + expect(JSON.parse(calls[1]![0])).toStrictEqual({ message: 'y' }) +}) + +test('gcpStructuredLogger survives a circular value', () => { + const circular: AnyObject = { n: 1 } + circular['self'] = circular + + commonLoggerCreate(gcpStructuredLogger, {}).log('hello', { circular }) + + const entry = JSON.parse(calls[0]![0]) + expect(entry.message).toBe('hello') + expect(entry.circular.n).toBe(1) + expect(entry.circular.self).toContain('Circular') +}) + test('devLogger prints the log entry', () => { commonLoggerCreate(devLogger, { accountId: 'a1' }).log('hello', { n: 1 }) diff --git a/packages/backend-lib/src/server/logMiddleware.ts b/packages/backend-lib/src/server/logMiddleware.ts index 4e22d697c..22c6dde1f 100644 --- a/packages/backend-lib/src/server/logMiddleware.ts +++ b/packages/backend-lib/src/server/logMiddleware.ts @@ -2,6 +2,7 @@ import { inspect } from 'node:util' import { _isPlainObject } from '@naturalcycles/js-lib' import { _anyToErrorObject } from '@naturalcycles/js-lib/error' import type { CommonLogger } from '@naturalcycles/js-lib/log' +import { _safeJsonStringify } from '@naturalcycles/js-lib/string/safeJsonStringify.js' import { _objectAssign } from '@naturalcycles/js-lib/types' import type { AnyObject } from '@naturalcycles/js-lib/types' import { _inspect } from '@naturalcycles/nodejs-lib' @@ -53,15 +54,15 @@ export const ciLogger: CommonLogger = { // Cloud Run logging: https://cloud.google.com/run/docs/logging function writeGCPStructuredLog(meta: AnyObject, args: any[]): void { if (args.length === 1 && _isPlainObject(args[0])) { - const { msg, err, ...rest } = args[0] - let message = msg - let errObject = err + const { msg, err, ...entry } = args[0] if (err instanceof Error) { - errObject = _anyToErrorObject(err) - const errStack = inspect(err) - message = msg ? `${msg}\n${errStack}` : errStack + entry['err'] = _anyToErrorObject(err) + entry['message'] = msg ? `${msg}\n${inspect(err)}` : inspect(err) + } else { + if (err !== undefined) entry['err'] = err + if (msg !== undefined) entry['message'] = msg } - console.log(JSON.stringify({ ...rest, message, err: errObject, ...meta })) + console.log(_safeJsonStringify({ ...entry, ...meta })) return } diff --git a/packages/js-lib/src/is.util.ts b/packages/js-lib/src/is.util.ts index 754c469b4..759fbd11b 100644 --- a/packages/js-lib/src/is.util.ts +++ b/packages/js-lib/src/is.util.ts @@ -33,7 +33,7 @@ export function _isObject(obj: any): obj is AnyObject { } /** - * Returns true if item is an object literal, e.g not an Array, Error, RegExp or class instance. + * Returns true if item is an object literal: not an Array, Error, RegExp or class instance. */ export function _isPlainObject(v: any): v is PlainObject { if (typeof v !== 'object' || v === null) return false From 2a510b4d68972911420c23c1f64ed4ace86c5611 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Fable 5.1)" Date: Wed, 23 Sep 2026 14:25:49 +0200 Subject: [PATCH 8/8] refactor(backend-lib): plain-text log path is the early exit, structured entry is the body Claude-Session: https://claude.ai/code/session_0167KuoFQNJXsXrM6mc3R9ux --- .../backend-lib/src/server/logMiddleware.ts | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/backend-lib/src/server/logMiddleware.ts b/packages/backend-lib/src/server/logMiddleware.ts index 22c6dde1f..7de89bb16 100644 --- a/packages/backend-lib/src/server/logMiddleware.ts +++ b/packages/backend-lib/src/server/logMiddleware.ts @@ -53,46 +53,46 @@ export const ciLogger: CommonLogger = { // Documented here: https://cloud.google.com/logging/docs/structured-logging // Cloud Run logging: https://cloud.google.com/run/docs/logging function writeGCPStructuredLog(meta: AnyObject, args: any[]): void { - if (args.length === 1 && _isPlainObject(args[0])) { - const { msg, err, ...entry } = args[0] - if (err instanceof Error) { - entry['err'] = _anyToErrorObject(err) - entry['message'] = msg ? `${msg}\n${inspect(err)}` : inspect(err) - } else { - if (err !== undefined) entry['err'] = err - if (msg !== undefined) entry['message'] = msg - } - console.log(_safeJsonStringify({ ...entry, ...meta })) + if (args.length !== 1 || !_isPlainObject(args[0])) { + console.log( + JSON.stringify({ + message: args.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '), + ...meta, + }), + ) return } - console.log( - JSON.stringify({ - message: args.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '), - ...meta, - }), - ) + const { msg, err, ...entry } = args[0] + if (err instanceof Error) { + entry['err'] = _anyToErrorObject(err) + entry['message'] = msg ? `${msg}\n${inspect(err)}` : inspect(err) + } else { + if (err !== undefined) entry['err'] = err + if (msg !== undefined) entry['message'] = msg + } + console.log(_safeJsonStringify({ ...entry, ...meta })) } function logToDev(requestId: string | null, args: any[]): void { // Run on local machine - if (args.length === 1 && _isPlainObject(args[0])) { - const { msg, err, ...rest } = args[0] - const parts: string[] = [] - if (requestId) parts.push(dimGrey(`[${requestId}]`)) - if (msg !== undefined) parts.push(String(msg)) - if (err !== undefined) parts.push(_inspect(err, { includeErrorStack: true, colors: true })) - if (Object.keys(rest).length) parts.push(dimGrey(_inspect(rest, { colors: false }))) - console.log(parts.join(' ')) + if (args.length !== 1 || !_isPlainObject(args[0])) { + console.log( + [ + requestId ? [dimGrey(`[${requestId}]`)] : [], + ...args.map(a => _inspect(a, { includeErrorStack: true, colors: true })), + ].join(' '), + ) return } - console.log( - [ - requestId ? [dimGrey(`[${requestId}]`)] : [], - ...args.map(a => _inspect(a, { includeErrorStack: true, colors: true })), - ].join(' '), - ) + const { msg, err, ...rest } = args[0] + const parts: string[] = [] + if (requestId) parts.push(dimGrey(`[${requestId}]`)) + if (msg !== undefined) parts.push(String(msg)) + if (err !== undefined) parts.push(_inspect(err, { includeErrorStack: true, colors: true })) + if (Object.keys(rest).length) parts.push(dimGrey(_inspect(rest, { colors: false }))) + console.log(parts.join(' ')) } /**