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/README.md b/logger/README.md index 83f36ce..2875917 100644 --- a/logger/README.md +++ b/logger/README.md @@ -15,22 +15,34 @@ 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 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 -logger.log({ - level: 'alert', - message: 'my log message', - source: 'folder-a/file-b/function-c', - data: { name: 'my-data' }, +import logger from '@frytg/logger/browser'; + +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 @@ -65,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 cfb2177..b99f1be 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.1.0", + "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..8b41199 --- /dev/null +++ b/logger/logger-browser.test.ts @@ -0,0 +1,89 @@ +import { test } from '@cross/test' +import { assertEquals, assertExists } from '@std/assert' +import sinon from 'sinon' + +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) => { + loggedOutput = output + }) + + logger.info('test message', { + source: 'test-source', + data: { test: 'data' }, + }) + + const loggedData = JSON.parse(loggedOutput) + 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() +}) + +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') + + 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 in development builds', () => { + assertEquals(logger.level, isDevBuild() ? '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() +}) + +/** + * 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 new file mode 100644 index 0000000..c9ca4b0 --- /dev/null +++ b/logger/logger-browser.ts @@ -0,0 +1,120 @@ +// deno-lint-ignore-file no-console +/** + * @module + * A browser-safe structured console logger for frontend apps. + */ + +import { type SerializedError, serializeError } from './serialize-error.ts' +import { shouldLog, SYSLOG_LEVELS, type SyslogLevel } from './syslog-levels.ts' + +type LogMetadata = { + source?: string + data?: Record + error?: unknown + [key: string]: unknown +} + +type LogEvent = LogMetadata & { + level: SyslogLevel + message: string + error?: SerializedError +} + +type ImportMetaEnv = Record + +type BrowserLogger = { + level: SyslogLevel + levels: typeof SYSLOG_LEVELS + log: (event: LogMetadata & { level: SyslogLevel; message: string }) => void +} & Record void> + +/** + * Determine whether the app is running in development mode. + * + * @returns {boolean} True when running in a development build. + */ +const isDev = (): boolean => { + const metaEnv = (import.meta as ImportMeta & { env?: ImportMetaEnv }).env + if (metaEnv?.DEV === true) return true + if (metaEnv?.MODE === 'development') return true + return false +} + +/** + * Resolve the minimum log level for the current build. + * + * @returns {SyslogLevel} The configured minimum log level. + */ +const resolveMinLevel = (): SyslogLevel => isDev() ? 'debug' : 'info' + +/** + * 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 console output. + * + * @param {LogEvent} event - The structured log event. + * @returns {string} Serialized log output. + */ +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. + * + * @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 = { + 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)) + }, + } 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.info('my log message', { + * source: 'components/MyComponent', + * data: { userId: '123' }, + * }) + * ``` + */ +export const logger: BrowserLogger = createBrowserLogger() + +export default logger 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]