From b2f3479b12210720820f4832930f8d28574e4aa1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 08:36:29 +0000 Subject: [PATCH 1/6] feat(logger): add browser entry path for client-side logging The default Winston-based logger cannot bundle for browser targets due to node:os, node:process, and Winston dependencies. Add @frytg/logger/browser as a lightweight console-based alternative with the same log event shape. Co-authored-by: Dan --- logger/README.md | 12 ++ logger/deno.jsonc | 7 +- logger/logger-browser.test.ts | 100 ++++++++++++++++ logger/logger-browser.ts | 220 ++++++++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 logger/logger-browser.test.ts create mode 100644 logger/logger-browser.ts diff --git a/logger/README.md b/logger/README.md index 83f36ce..6fb61f0 100644 --- a/logger/README.md +++ b/logger/README.md @@ -15,10 +15,22 @@ Debug logs will only be logged if the env `STAGE` is set to `dev`. ## Usage +### Server (Node.js, Deno, Bun) + ```ts import logger from '@frytg/logger'; ``` +### Browser + +Use the dedicated browser entry. It avoids Node built-ins and Winston, outputs structured JSON via `console`, and keeps the same log event shape. + +```ts +import logger from '@frytg/logger/browser'; +``` + +The default `@frytg/logger` entry is server-only and will not bundle for browser targets. + ```ts logger.log({ level: 'alert', diff --git a/logger/deno.jsonc b/logger/deno.jsonc index cfb2177..0ede6c0 100644 --- a/logger/deno.jsonc +++ b/logger/deno.jsonc @@ -1,8 +1,11 @@ { "$schema": "https://jsr.io/schema/config-file.v1.json", "name": "@frytg/logger", - "version": "0.0.4", - "exports": "./logger.ts", + "version": "0.0.5", + "exports": { + ".": "./logger.ts", + "./browser": "./logger-browser.ts" + }, "imports": { "winston": "npm:winston@^3.19.0" }, diff --git a/logger/logger-browser.test.ts b/logger/logger-browser.test.ts new file mode 100644 index 0000000..0b7b9cf --- /dev/null +++ b/logger/logger-browser.test.ts @@ -0,0 +1,100 @@ +import { test } from '@cross/test' +import { assertEquals, assertExists } from '@std/assert' +import sinon from 'sinon' + +import { detectBrowserRuntime, logger } from './logger-browser.ts' + +test('detectBrowserRuntime - returns the same value on repeated calls', () => { + const first = detectBrowserRuntime() + const second = detectBrowserRuntime() + + assertEquals(second, first) +}) + +test('logger browser - includes global context in log events', () => { + const globalScope = globalThis as { __ENV__?: Record } + const previousEnv = globalScope.__ENV__ + globalScope.__ENV__ = { + K_REVISION: 'test-revision', + SERVICE_NAME: 'test-service', + STAGE: 'test', + npm_package_version: '1.0.0', + } + + const consoleStub = sinon.stub(console, 'log') + let loggedOutput = '' + consoleStub.callsFake((output: string) => { + loggedOutput = output + }) + + logger.info('test message', { + source: 'test-source', + data: { test: 'data' }, + }) + + const loggedData = JSON.parse(loggedOutput) + assertEquals(loggedData.host, 'test-revision') + assertEquals(loggedData.serviceName, 'test-service') + assertEquals(loggedData.stage, 'test') + assertEquals(loggedData.version, '1.0.0') + assertExists(loggedData.runtime) + + consoleStub.restore() + if (previousEnv === undefined) { + delete globalScope.__ENV__ + } else { + globalScope.__ENV__ = previousEnv + } +}) + +test('logger browser - formats errors correctly', () => { + const testError = new Error('test error') + const consoleStub = sinon.stub(console, 'error') + let loggedOutput = '' + consoleStub.callsFake((output: string) => { + loggedOutput = output + }) + + logger.error('error occurred', { + source: 'test-source', + error: testError, + }) + + const loggedData = JSON.parse(loggedOutput) + assertExists(loggedData.error.message) + assertExists(loggedData.error.stack) + assertEquals(loggedData.error.message, 'test error') + assertExists(loggedData.runtime) + + consoleStub.restore() +}) + +test('logger browser - has correct syslog levels', () => { + const expectedLevels = { + emerg: 0, + alert: 1, + crit: 2, + error: 3, + warning: 4, + notice: 5, + info: 6, + debug: 7, + } + + assertEquals(logger.levels, expectedLevels) +}) + +test('logger browser - sets debug level from environment', () => { + const stage = (globalThis as { __ENV__?: Record }).__ENV__?.STAGE + assertEquals(logger.level, stage === 'dev' ? 'debug' : 'info') +}) + +test('logger browser - does not log below configured level', () => { + if (logger.level === 'debug') return + + const consoleStub = sinon.stub(console, 'debug') + logger.debug('should not log', { source: 'test-source' }) + + assertEquals(consoleStub.called, false) + consoleStub.restore() +}) diff --git a/logger/logger-browser.ts b/logger/logger-browser.ts new file mode 100644 index 0000000..463edca --- /dev/null +++ b/logger/logger-browser.ts @@ -0,0 +1,220 @@ +// deno-lint-ignore-file no-console +/** + * @module + * A browser-safe logger with the same log event shape as `@frytg/logger`. + */ + +const SYSLOG_LEVELS = { + emerg: 0, + alert: 1, + crit: 2, + error: 3, + warning: 4, + notice: 5, + info: 6, + debug: 7, +} as const + +type SyslogLevel = keyof typeof SYSLOG_LEVELS + +type LogMetadata = { + source?: string + data?: Record + error?: unknown + [key: string]: unknown +} + +type LogEvent = LogMetadata & { + level: SyslogLevel + message: string + host: string | null + serviceName: string | null + stage: string | null + version: string | null + region: string | null + runtime: string + error?: { + message: string + stack?: string + [key: string]: unknown + } +} + +type ImportMetaEnv = Record + +type BrowserLogger = { + level: SyslogLevel + levels: typeof SYSLOG_LEVELS + log: (event: LogMetadata & { level: SyslogLevel; message: string }) => void +} & Record void> + +let cachedBrowserRuntime: string | undefined + +/** + * Read a configuration value from browser-safe environment sources. + * + * @param {string} key - Environment variable name. + * @returns {string | undefined} The value when present. + */ +const readEnv = (key: string): string | undefined => { + const metaEnv = (import.meta as ImportMeta & { env?: ImportMetaEnv }).env + const metaValue = metaEnv?.[key] + if (typeof metaValue === 'string' && metaValue !== '') return metaValue + + const globalEnv = (globalThis as { __ENV__?: Record }).__ENV__ + const globalValue = globalEnv?.[key] + if (globalValue !== undefined && globalValue !== '') return globalValue + + return undefined +} + +/** + * Detect and return the browser runtime label. + * + * @returns {string} A browser runtime identifier. + */ +export const detectBrowserRuntime = (): string => { + if (cachedBrowserRuntime !== undefined) return cachedBrowserRuntime + + if (typeof navigator !== 'undefined' && navigator.userAgent) { + cachedBrowserRuntime = `browser-${navigator.userAgent}` + } else { + cachedBrowserRuntime = 'browser-unknown' + } + + return cachedBrowserRuntime +} + +/** + * Resolve the minimum log level from environment configuration. + * + * @returns {SyslogLevel} The configured minimum log level. + */ +const resolveMinLevel = (): SyslogLevel => { + const stage = readEnv('STAGE') ?? readEnv('NODE_ENV') ?? readEnv('MODE') ?? readEnv('VITE_STAGE') + return stage === 'dev' ? 'debug' : 'info' +} + +/** + * Determine whether local, colorized output should be used. + * + * @returns {boolean} True when local output is enabled. + */ +const isLocalOutput = (): boolean => readEnv('IS_LOCAL') === 'true' || readEnv('DEV') === 'true' + +/** + * Serialize an error for structured logging. + * + * @param {unknown} error - The error value to serialize. + * @returns {LogEvent['error'] | undefined} Serialized error fields. + */ +const serializeError = (error: unknown): LogEvent['error'] | undefined => { + if (!(error instanceof Error)) return undefined + + return { + ...error, + message: error.message, + stack: error.stack, + } +} + +/** + * Build global context fields injected into each log event. + * + * @returns {Omit} Global log context. + */ +const buildGlobalContext = (): Omit => ({ + host: readEnv('K_REVISION') ?? + readEnv('HOST') ?? + (typeof globalThis.location !== 'undefined' ? globalThis.location.hostname : null), + serviceName: readEnv('SERVICE_NAME') ?? readEnv('K_SERVICE') ?? readEnv('VITE_SERVICE_NAME') ?? null, + stage: readEnv('STAGE') ?? readEnv('NODE_ENV') ?? readEnv('MODE') ?? readEnv('VITE_STAGE') ?? null, + version: readEnv('SERVICE_VERSION') ?? readEnv('npm_package_version') ?? readEnv('VITE_SERVICE_VERSION') ?? + null, + region: readEnv('REGION') ?? readEnv('AWS_REGION') ?? readEnv('FLY_REGION') ?? readEnv('DENO_REGION') ?? + null, + runtime: detectBrowserRuntime(), +}) + +/** + * Determine whether a log event should be emitted for the configured level. + * + * @param {SyslogLevel} eventLevel - The event severity. + * @param {SyslogLevel} configuredLevel - The configured minimum level. + * @returns {boolean} True when the event should be logged. + */ +const shouldLog = (eventLevel: SyslogLevel, configuredLevel: SyslogLevel): boolean => + SYSLOG_LEVELS[eventLevel] <= SYSLOG_LEVELS[configuredLevel] + +/** + * Select the console method that best matches a syslog level. + * + * @param {SyslogLevel} level - The syslog level. + * @returns {'debug' | 'error' | 'log' | 'warn'} The console method to use. + */ +const consoleMethodForLevel = (level: SyslogLevel): 'debug' | 'error' | 'log' | 'warn' => { + if (level === 'error' || level === 'crit' || level === 'emerg' || level === 'alert') return 'error' + if (level === 'warning') return 'warn' + if (level === 'debug') return 'debug' + return 'log' +} + +/** + * Format a log event for output. + * + * @param {LogEvent} event - The structured log event. + * @returns {string} Serialized log output. + */ +const formatLogEvent = (event: LogEvent): string => + isLocalOutput() ? JSON.stringify(event, null, 4) : JSON.stringify(event) + +/** + * Create a browser-safe logger with syslog levels and structured JSON output. + * + * @returns {BrowserLogger} Configured browser logger. + */ +const createBrowserLogger = (): BrowserLogger => { + const logger: BrowserLogger = { + level: resolveMinLevel(), + levels: SYSLOG_LEVELS, + log: (event) => { + if (!shouldLog(event.level, logger.level)) return + + const serializedError = serializeError(event.error) + const logEvent: LogEvent = { + ...buildGlobalContext(), + ...event, + ...(serializedError ? { error: serializedError } : {}), + } + + console[consoleMethodForLevel(event.level)](formatLogEvent(logEvent)) + }, + } as BrowserLogger + + for (const level of Object.keys(SYSLOG_LEVELS) as SyslogLevel[]) { + logger[level] = (message: string, meta: LogMetadata = {}) => { + logger.log({ level, message, ...meta }) + } + } + + return logger +} + +/** + * Use the exported logger to log messages in browser environments. + * + * @example basic log + * ```ts + * import logger from '@frytg/logger/browser' + * + * logger.log({ + * level: 'debug', + * message: 'my log message', + * source: 'folder-a/file-b/function-c', + * data: { name: 'my-data' }, + * }) + * ``` + */ +export const logger = createBrowserLogger() + +export default logger From be801b6c52111261d62e0a07b4b11bfbd1181be3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 08:37:29 +0000 Subject: [PATCH 2/6] fix(logger): add explicit BrowserLogger type to public export Satisfies Deno lint rule requiring explicit types on public API exports. Co-authored-by: Dan --- logger/logger-browser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logger/logger-browser.ts b/logger/logger-browser.ts index 463edca..1eb3e91 100644 --- a/logger/logger-browser.ts +++ b/logger/logger-browser.ts @@ -215,6 +215,6 @@ const createBrowserLogger = (): BrowserLogger => { * }) * ``` */ -export const logger = createBrowserLogger() +export const logger: BrowserLogger = createBrowserLogger() export default logger From 9c184e51f020e81bd6d2479fd6ed338142750e5e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 08:39:30 +0000 Subject: [PATCH 3/6] fix(logger): satisfy Deno exactOptionalPropertyTypes in browser logger Extract GlobalContext type and avoid assigning undefined optional fields so the browser entry type-checks under the workspace strict TS config. Co-authored-by: Dan --- logger/logger-browser.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/logger/logger-browser.ts b/logger/logger-browser.ts index 1eb3e91..4ad8d73 100644 --- a/logger/logger-browser.ts +++ b/logger/logger-browser.ts @@ -24,15 +24,18 @@ type LogMetadata = { [key: string]: unknown } -type LogEvent = LogMetadata & { - level: SyslogLevel - message: string +type GlobalContext = { host: string | null serviceName: string | null stage: string | null version: string | null region: string | null runtime: string +} + +type LogEvent = LogMetadata & GlobalContext & { + level: SyslogLevel + message: string error?: { message: string stack?: string @@ -114,16 +117,16 @@ const serializeError = (error: unknown): LogEvent['error'] | undefined => { return { ...error, message: error.message, - stack: error.stack, + ...(error.stack !== undefined ? { stack: error.stack } : {}), } } /** * Build global context fields injected into each log event. * - * @returns {Omit} Global log context. + * @returns {GlobalContext} Global log context. */ -const buildGlobalContext = (): Omit => ({ +const buildGlobalContext = (): GlobalContext => ({ host: readEnv('K_REVISION') ?? readEnv('HOST') ?? (typeof globalThis.location !== 'undefined' ? globalThis.location.hostname : null), @@ -183,8 +186,11 @@ const createBrowserLogger = (): BrowserLogger => { const serializedError = serializeError(event.error) const logEvent: LogEvent = { ...buildGlobalContext(), - ...event, - ...(serializedError ? { error: serializedError } : {}), + level: event.level, + message: event.message, + ...(event.source !== undefined ? { source: event.source } : {}), + ...(event.data !== undefined ? { data: event.data } : {}), + ...(serializedError !== undefined ? { error: serializedError } : {}), } console[consoleMethodForLevel(event.level)](formatLogEvent(logEvent)) From d8ae3e35ee7685f2328e9bd03dff03af22899999 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 11:09:14 +0000 Subject: [PATCH 4/6] refactor(logger): simplify browser logger for frontend console use Drop server deployment env injection from the browser entry. The logger now logs only caller-provided fields and uses import.meta.env dev signals for debug level and pretty-printed output. Co-authored-by: Dan --- logger/README.md | 27 ++++++---- logger/deno.jsonc | 2 +- logger/logger-browser.test.ts | 53 ++++++++----------- logger/logger-browser.ts | 96 ++++++----------------------------- 4 files changed, 53 insertions(+), 125 deletions(-) diff --git a/logger/README.md b/logger/README.md index 6fb61f0..2875917 100644 --- a/logger/README.md +++ b/logger/README.md @@ -23,26 +23,26 @@ import logger from '@frytg/logger'; ### Browser -Use the dedicated browser entry. It avoids Node built-ins and Winston, outputs structured JSON via `console`, and keeps the same log event shape. +Use the dedicated browser entry for frontend apps (Vue, React, etc.). It is a structured `console` replacement with the same call style as the server logger, without server deployment env injection. ```ts import logger from '@frytg/logger/browser'; -``` - -The default `@frytg/logger` entry is server-only and will not bundle for browser targets. -```ts -logger.log({ - level: 'alert', - message: 'my log message', - source: 'folder-a/file-b/function-c', - data: { name: 'my-data' }, +logger.info('user signed in', { + source: 'components/LoginForm', + data: { method: 'oauth' }, }); ``` +In development builds (`import.meta.env.DEV` or `import.meta.env.MODE === 'development'`), debug logs are enabled and output is pretty-printed JSON. Production builds log `info` and above as compact JSON. + +The default `@frytg/logger` entry is server-only and will not bundle for browser targets. + ## Configuration -The logger accesses and injects several env variables to each log event (envs listed in order of priority): +### Server + +The server logger accesses and injects several env variables to each log event (envs listed in order of priority): - `host` - the host name (e.g. `my-function`) - from env `K_REVISION` - set by Knative such as Google Cloud Run @@ -77,6 +77,11 @@ Additionally these environment variables are triggering different logging format - `IS_LOCAL` - set to `true` to use a more human readable, colorized output format that uses multiple lines - `STAGE` - set to `dev` to enable debug logs +### Browser + +- `import.meta.env.DEV` - Vite development builds enable debug logs and pretty-printed output +- `import.meta.env.MODE` - `development` also enables debug logs and pretty-printed output + ## Log Levels It is currently pre-configured with the diff --git a/logger/deno.jsonc b/logger/deno.jsonc index 0ede6c0..f53239d 100644 --- a/logger/deno.jsonc +++ b/logger/deno.jsonc @@ -1,7 +1,7 @@ { "$schema": "https://jsr.io/schema/config-file.v1.json", "name": "@frytg/logger", - "version": "0.0.5", + "version": "0.0.6", "exports": { ".": "./logger.ts", "./browser": "./logger-browser.ts" diff --git a/logger/logger-browser.test.ts b/logger/logger-browser.test.ts index 0b7b9cf..8b41199 100644 --- a/logger/logger-browser.test.ts +++ b/logger/logger-browser.test.ts @@ -2,25 +2,9 @@ import { test } from '@cross/test' import { assertEquals, assertExists } from '@std/assert' import sinon from 'sinon' -import { detectBrowserRuntime, logger } from './logger-browser.ts' - -test('detectBrowserRuntime - returns the same value on repeated calls', () => { - const first = detectBrowserRuntime() - const second = detectBrowserRuntime() - - assertEquals(second, first) -}) - -test('logger browser - includes global context in log events', () => { - const globalScope = globalThis as { __ENV__?: Record } - const previousEnv = globalScope.__ENV__ - globalScope.__ENV__ = { - K_REVISION: 'test-revision', - SERVICE_NAME: 'test-service', - STAGE: 'test', - npm_package_version: '1.0.0', - } +import { logger } from './logger-browser.ts' +test('logger browser - logs structured event fields', () => { const consoleStub = sinon.stub(console, 'log') let loggedOutput = '' consoleStub.callsFake((output: string) => { @@ -33,18 +17,13 @@ test('logger browser - includes global context in log events', () => { }) const loggedData = JSON.parse(loggedOutput) - assertEquals(loggedData.host, 'test-revision') - assertEquals(loggedData.serviceName, 'test-service') - assertEquals(loggedData.stage, 'test') - assertEquals(loggedData.version, '1.0.0') - assertExists(loggedData.runtime) + assertEquals(loggedData.message, 'test message') + assertEquals(loggedData.level, 'info') + assertEquals(loggedData.source, 'test-source') + assertEquals(loggedData.data, { test: 'data' }) + assertEquals(loggedData.host, undefined) consoleStub.restore() - if (previousEnv === undefined) { - delete globalScope.__ENV__ - } else { - globalScope.__ENV__ = previousEnv - } }) test('logger browser - formats errors correctly', () => { @@ -64,7 +43,6 @@ test('logger browser - formats errors correctly', () => { assertExists(loggedData.error.message) assertExists(loggedData.error.stack) assertEquals(loggedData.error.message, 'test error') - assertExists(loggedData.runtime) consoleStub.restore() }) @@ -84,9 +62,8 @@ test('logger browser - has correct syslog levels', () => { assertEquals(logger.levels, expectedLevels) }) -test('logger browser - sets debug level from environment', () => { - const stage = (globalThis as { __ENV__?: Record }).__ENV__?.STAGE - assertEquals(logger.level, stage === 'dev' ? 'debug' : 'info') +test('logger browser - sets debug level in development builds', () => { + assertEquals(logger.level, isDevBuild() ? 'debug' : 'info') }) test('logger browser - does not log below configured level', () => { @@ -98,3 +75,15 @@ test('logger browser - does not log below configured level', () => { assertEquals(consoleStub.called, false) consoleStub.restore() }) + +/** + * Mirror the browser logger development-mode check for assertions. + * + * @returns {boolean} True when the current build is development. + */ +const isDevBuild = (): boolean => { + const metaEnv = (import.meta as ImportMeta & { env?: Record }).env + if (metaEnv?.DEV === true) return true + if (metaEnv?.MODE === 'development') return true + return false +} diff --git a/logger/logger-browser.ts b/logger/logger-browser.ts index 4ad8d73..af336b6 100644 --- a/logger/logger-browser.ts +++ b/logger/logger-browser.ts @@ -1,7 +1,7 @@ // deno-lint-ignore-file no-console /** * @module - * A browser-safe logger with the same log event shape as `@frytg/logger`. + * A browser-safe structured console logger for frontend apps. */ const SYSLOG_LEVELS = { @@ -24,16 +24,7 @@ type LogMetadata = { [key: string]: unknown } -type GlobalContext = { - host: string | null - serviceName: string | null - stage: string | null - version: string | null - region: string | null - runtime: string -} - -type LogEvent = LogMetadata & GlobalContext & { +type LogEvent = LogMetadata & { level: SyslogLevel message: string error?: { @@ -51,59 +42,24 @@ type BrowserLogger = { log: (event: LogMetadata & { level: SyslogLevel; message: string }) => void } & Record void> -let cachedBrowserRuntime: string | undefined - /** - * Read a configuration value from browser-safe environment sources. + * Determine whether the app is running in development mode. * - * @param {string} key - Environment variable name. - * @returns {string | undefined} The value when present. + * @returns {boolean} True when running in a development build. */ -const readEnv = (key: string): string | undefined => { +const isDev = (): boolean => { const metaEnv = (import.meta as ImportMeta & { env?: ImportMetaEnv }).env - const metaValue = metaEnv?.[key] - if (typeof metaValue === 'string' && metaValue !== '') return metaValue - - const globalEnv = (globalThis as { __ENV__?: Record }).__ENV__ - const globalValue = globalEnv?.[key] - if (globalValue !== undefined && globalValue !== '') return globalValue - - return undefined -} - -/** - * Detect and return the browser runtime label. - * - * @returns {string} A browser runtime identifier. - */ -export const detectBrowserRuntime = (): string => { - if (cachedBrowserRuntime !== undefined) return cachedBrowserRuntime - - if (typeof navigator !== 'undefined' && navigator.userAgent) { - cachedBrowserRuntime = `browser-${navigator.userAgent}` - } else { - cachedBrowserRuntime = 'browser-unknown' - } - - return cachedBrowserRuntime + if (metaEnv?.DEV === true) return true + if (metaEnv?.MODE === 'development') return true + return false } /** - * Resolve the minimum log level from environment configuration. + * Resolve the minimum log level for the current build. * * @returns {SyslogLevel} The configured minimum log level. */ -const resolveMinLevel = (): SyslogLevel => { - const stage = readEnv('STAGE') ?? readEnv('NODE_ENV') ?? readEnv('MODE') ?? readEnv('VITE_STAGE') - return stage === 'dev' ? 'debug' : 'info' -} - -/** - * Determine whether local, colorized output should be used. - * - * @returns {boolean} True when local output is enabled. - */ -const isLocalOutput = (): boolean => readEnv('IS_LOCAL') === 'true' || readEnv('DEV') === 'true' +const resolveMinLevel = (): SyslogLevel => isDev() ? 'debug' : 'info' /** * Serialize an error for structured logging. @@ -121,24 +77,6 @@ const serializeError = (error: unknown): LogEvent['error'] | undefined => { } } -/** - * Build global context fields injected into each log event. - * - * @returns {GlobalContext} Global log context. - */ -const buildGlobalContext = (): GlobalContext => ({ - host: readEnv('K_REVISION') ?? - readEnv('HOST') ?? - (typeof globalThis.location !== 'undefined' ? globalThis.location.hostname : null), - serviceName: readEnv('SERVICE_NAME') ?? readEnv('K_SERVICE') ?? readEnv('VITE_SERVICE_NAME') ?? null, - stage: readEnv('STAGE') ?? readEnv('NODE_ENV') ?? readEnv('MODE') ?? readEnv('VITE_STAGE') ?? null, - version: readEnv('SERVICE_VERSION') ?? readEnv('npm_package_version') ?? readEnv('VITE_SERVICE_VERSION') ?? - null, - region: readEnv('REGION') ?? readEnv('AWS_REGION') ?? readEnv('FLY_REGION') ?? readEnv('DENO_REGION') ?? - null, - runtime: detectBrowserRuntime(), -}) - /** * Determine whether a log event should be emitted for the configured level. * @@ -163,13 +101,12 @@ const consoleMethodForLevel = (level: SyslogLevel): 'debug' | 'error' | 'log' | } /** - * Format a log event for output. + * Format a log event for console output. * * @param {LogEvent} event - The structured log event. * @returns {string} Serialized log output. */ -const formatLogEvent = (event: LogEvent): string => - isLocalOutput() ? JSON.stringify(event, null, 4) : JSON.stringify(event) +const formatLogEvent = (event: LogEvent): string => isDev() ? JSON.stringify(event, null, 4) : JSON.stringify(event) /** * Create a browser-safe logger with syslog levels and structured JSON output. @@ -185,7 +122,6 @@ const createBrowserLogger = (): BrowserLogger => { const serializedError = serializeError(event.error) const logEvent: LogEvent = { - ...buildGlobalContext(), level: event.level, message: event.message, ...(event.source !== undefined ? { source: event.source } : {}), @@ -213,11 +149,9 @@ const createBrowserLogger = (): BrowserLogger => { * ```ts * import logger from '@frytg/logger/browser' * - * logger.log({ - * level: 'debug', - * message: 'my log message', - * source: 'folder-a/file-b/function-c', - * data: { name: 'my-data' }, + * logger.info('my log message', { + * source: 'components/MyComponent', + * data: { userId: '123' }, * }) * ``` */ From 54b6d0ed4701f57dbeed4343a6fbeb17e47fd76a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 11:18:02 +0000 Subject: [PATCH 5/6] refactor(logger): extract shared syslog levels and error serialization Move syslog level definitions, level filtering, and error formatting into shared modules used by both the Winston server logger and browser entry. Co-authored-by: Dan --- logger/deno.jsonc | 2 +- logger/logger-browser.ts | 46 +++------------------------------------ logger/logger.ts | 16 +++++++------- logger/serialize-error.ts | 26 ++++++++++++++++++++++ logger/syslog-levels.ts | 27 +++++++++++++++++++++++ 5 files changed, 65 insertions(+), 52 deletions(-) create mode 100644 logger/serialize-error.ts create mode 100644 logger/syslog-levels.ts diff --git a/logger/deno.jsonc b/logger/deno.jsonc index f53239d..a3576e5 100644 --- a/logger/deno.jsonc +++ b/logger/deno.jsonc @@ -1,7 +1,7 @@ { "$schema": "https://jsr.io/schema/config-file.v1.json", "name": "@frytg/logger", - "version": "0.0.6", + "version": "0.0.7", "exports": { ".": "./logger.ts", "./browser": "./logger-browser.ts" diff --git a/logger/logger-browser.ts b/logger/logger-browser.ts index af336b6..c9ca4b0 100644 --- a/logger/logger-browser.ts +++ b/logger/logger-browser.ts @@ -4,18 +4,8 @@ * A browser-safe structured console logger for frontend apps. */ -const SYSLOG_LEVELS = { - emerg: 0, - alert: 1, - crit: 2, - error: 3, - warning: 4, - notice: 5, - info: 6, - debug: 7, -} as const - -type SyslogLevel = keyof typeof SYSLOG_LEVELS +import { type SerializedError, serializeError } from './serialize-error.ts' +import { shouldLog, SYSLOG_LEVELS, type SyslogLevel } from './syslog-levels.ts' type LogMetadata = { source?: string @@ -27,11 +17,7 @@ type LogMetadata = { type LogEvent = LogMetadata & { level: SyslogLevel message: string - error?: { - message: string - stack?: string - [key: string]: unknown - } + error?: SerializedError } type ImportMetaEnv = Record @@ -61,32 +47,6 @@ const isDev = (): boolean => { */ const resolveMinLevel = (): SyslogLevel => isDev() ? 'debug' : 'info' -/** - * Serialize an error for structured logging. - * - * @param {unknown} error - The error value to serialize. - * @returns {LogEvent['error'] | undefined} Serialized error fields. - */ -const serializeError = (error: unknown): LogEvent['error'] | undefined => { - if (!(error instanceof Error)) return undefined - - return { - ...error, - message: error.message, - ...(error.stack !== undefined ? { stack: error.stack } : {}), - } -} - -/** - * Determine whether a log event should be emitted for the configured level. - * - * @param {SyslogLevel} eventLevel - The event severity. - * @param {SyslogLevel} configuredLevel - The configured minimum level. - * @returns {boolean} True when the event should be logged. - */ -const shouldLog = (eventLevel: SyslogLevel, configuredLevel: SyslogLevel): boolean => - SYSLOG_LEVELS[eventLevel] <= SYSLOG_LEVELS[configuredLevel] - /** * Select the console method that best matches a syslog level. * diff --git a/logger/logger.ts b/logger/logger.ts index 348f28e..faf641e 100644 --- a/logger/logger.ts +++ b/logger/logger.ts @@ -7,19 +7,19 @@ import os from 'node:os' import process from 'node:process' import type { Logform, Logger } from 'winston' -import { config, createLogger, format, transports } from 'winston' +import { createLogger, format, transports } from 'winston' + +import { serializeError } from './serialize-error.ts' +import { SYSLOG_LEVELS } from './syslog-levels.ts' // set config once const hostName = os.hostname() // Format error objects const convertError = format((event) => { - if (event?.error instanceof Error) { - event.error = { - ...event.error, - message: event.error.message, - stack: event.error.stack, - } + const serializedError = serializeError(event?.error) + if (serializedError !== undefined) { + event.error = serializedError } return event }) @@ -115,7 +115,7 @@ const formatConfigLocal: Logform.Format = format.combine( */ export const logger: Logger = createLogger({ level: process.env.STAGE === 'dev' ? 'debug' : 'info', - levels: config.syslog.levels, + levels: SYSLOG_LEVELS, exitOnError: false, format: process.env.IS_LOCAL === 'true' ? formatConfigLocal : formatConfig, transports: [new transports.Console()], diff --git a/logger/serialize-error.ts b/logger/serialize-error.ts new file mode 100644 index 0000000..2555912 --- /dev/null +++ b/logger/serialize-error.ts @@ -0,0 +1,26 @@ +/** + * @module + * Shared error serialization for structured log events. + */ + +export type SerializedError = { + message: string + stack?: string + [key: string]: unknown +} + +/** + * Serialize an error for structured logging. + * + * @param {unknown} error - The error value to serialize. + * @returns {SerializedError | undefined} Serialized error fields. + */ +export const serializeError = (error: unknown): SerializedError | undefined => { + if (!(error instanceof Error)) return undefined + + return { + ...error, + message: error.message, + ...(error.stack !== undefined ? { stack: error.stack } : {}), + } +} diff --git a/logger/syslog-levels.ts b/logger/syslog-levels.ts new file mode 100644 index 0000000..46e6122 --- /dev/null +++ b/logger/syslog-levels.ts @@ -0,0 +1,27 @@ +/** + * @module + * Shared syslog level definitions used by server and browser loggers. + */ + +export const SYSLOG_LEVELS = { + emerg: 0, + alert: 1, + crit: 2, + error: 3, + warning: 4, + notice: 5, + info: 6, + debug: 7, +} as const + +export type SyslogLevel = keyof typeof SYSLOG_LEVELS + +/** + * Determine whether a log event should be emitted for the configured level. + * + * @param {SyslogLevel} eventLevel - The event severity. + * @param {SyslogLevel} configuredLevel - The configured minimum level. + * @returns {boolean} True when the event should be logged. + */ +export const shouldLog = (eventLevel: SyslogLevel, configuredLevel: SyslogLevel): boolean => + SYSLOG_LEVELS[eventLevel] <= SYSLOG_LEVELS[configuredLevel] From 91b1a2f3291e695b80e789719195a25d128c47f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 11:25:58 +0000 Subject: [PATCH 6/6] chore(logger): bump version to 0.1.0 and update changelog Co-authored-by: Dan --- logger/CHANGELOG.md | 6 ++++++ logger/deno.jsonc | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/logger/CHANGELOG.md b/logger/CHANGELOG.md index a5acd91..d002d2e 100644 --- a/logger/CHANGELOG.md +++ b/logger/CHANGELOG.md @@ -1,5 +1,11 @@ # Logger Changelog +## 2026-07-13 - 0.1.0 + +- feat: added `@frytg/logger/browser` entry for frontend apps as a structured `console` replacement +- feat: shared `syslog-levels` and `serialize-error` modules used by server and browser loggers +- refactor: browser logger logs caller-provided fields only; dev builds use `import.meta.env.DEV` / `MODE` + ## 2026-07-13 - 0.0.4 - feat: added Fly.io and Deno Deploy context to log events diff --git a/logger/deno.jsonc b/logger/deno.jsonc index a3576e5..b99f1be 100644 --- a/logger/deno.jsonc +++ b/logger/deno.jsonc @@ -1,7 +1,7 @@ { "$schema": "https://jsr.io/schema/config-file.v1.json", "name": "@frytg/logger", - "version": "0.0.7", + "version": "0.1.0", "exports": { ".": "./logger.ts", "./browser": "./logger-browser.ts"