Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions logger/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 23 additions & 6 deletions logger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions logger/deno.jsonc
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
89 changes: 89 additions & 0 deletions logger/logger-browser.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | boolean | undefined> }).env
if (metaEnv?.DEV === true) return true
if (metaEnv?.MODE === 'development') return true
return false
}
120 changes: 120 additions & 0 deletions logger/logger-browser.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
error?: unknown
[key: string]: unknown
}

type LogEvent = LogMetadata & {
level: SyslogLevel
message: string
error?: SerializedError
}

type ImportMetaEnv = Record<string, string | boolean | undefined>

type BrowserLogger = {
level: SyslogLevel
levels: typeof SYSLOG_LEVELS
log: (event: LogMetadata & { level: SyslogLevel; message: string }) => void
} & Record<SyslogLevel, (message: string, meta?: LogMetadata) => 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
16 changes: 8 additions & 8 deletions logger/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down Expand Up @@ -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()],
Expand Down
26 changes: 26 additions & 0 deletions logger/serialize-error.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}),
}
}
27 changes: 27 additions & 0 deletions logger/syslog-levels.ts
Original file line number Diff line number Diff line change
@@ -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]
Loading