Compare commits

..

5 Commits

Author SHA1 Message Date
6b4173d636 .gitignore for built packages created with npm pack 2025-01-07 08:53:25 -06:00
756ebf3c78 Allow LogMessageFormatter to return FlattenedLogMessages.
Originally the idea was that a log formatter should turn the LogMessage
into a string that can be appended via a LogAppender. However, all of
the default LogAppenders can handle objects. In particular, the
ConsoleLogAppender writes to the browser console which offers an
interactive UI experience when logging raw objects.

For LogAppenders that can only manage text, it seems an acceptable
design decision to require users to provide a LogMessageFormatter that
returns a string, or accept some sane default like JSON.stringify if
their formatter returns objects.
2025-01-07 08:48:37 -06:00
13941840ce Move back to npm (bun does not package correctly). 2025-01-02 22:53:10 -06:00
c3e2152afb Refactor to default to structured logging.
- Added `flattenMessage` and `FlattenedLogMessage` to default to
  structured logging.
- Rework the logic formatting messages for the ConsoleLogger.
- Add a more comprehensive README.
2025-01-02 22:50:10 -06:00
4a9f582ad8 Migrate to bun and eslint. 2025-01-02 17:01:39 -06:00
14 changed files with 413 additions and 85 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
node_modules/ node_modules/
dist/ dist/
*.sw? *.sw?
.lvimrc
jdbernard-logging-*.tgz

166
README.md Normal file
View File

@ -0,0 +1,166 @@
# Overview
`@jdbernard/logging` implements a simple but powerful logging system for
JavaScript applications based on the idea of heirarchical loggers. It heavily
inspired by the usage patterns of [log4j][], [logback][], and similar tools in other
ecosystems.
## Getting Started
Install the package from npm:
```bash
npm install @jdbernard/logging
```
Then, in your application, you can use the logging system like so:
```typescript
import { logService, ConsoleAppender, LogLevel } from '@jdbernard/logging';
logService.ROOT_LOGGER.appenders.push(new ConsoleAppender(LogLevel.INFO));
const logger = logService.getLogger('example');
logger.info('Starting application');
```
## Loggers and Appenders
The logging system is composed of two main components: loggers and appenders.
Loggers are used to create log events, which are then passed to the appenders
associated with the logger and it's parent. An appender is a function that
takes a log event and writes it to some destination, such as the console, a
file, or a network socket. Appenders also have a logging level threshold, which
determines which log events are acted upon by the appender.
Loggers are hierarchical, with the hierarchy defined by the logger name. When a
log message is generated on a *Logger*, it is sent to any appenders associated
with that logger, and then triggered on the parent logger, being sent to any of
the parent logger's appenders, and so on up the hierarchy. Similarly, when a
logging event is generated, it is filtered by the effective logging level of
the logger. If there is no threshold set on the logger, the effective logging
level is inherited from the parent logger. This pattern is explained in detail
in the [logback documentation][effective logging level] and applies in the same
manner to this library.
Together, the cascading effective logging threshold and the upward propogation
of log events to parent appenders allows for simple yet fine-grained control
over the logging output of an application.
## Layouts
One difference from other logging libraries is the absense of layouts. Layouts
are used in many logging libraries to format the log message before it is
written to the appender. This requires the assumption of all logs being written
to a text-based destination, which is not always the case for us.
In this library, all logs are internally stored as structured data, defined by
the *LogMessage* interface. Notably, the *message* field can be either a string
or an object, allowing for the addition of arbitrary fields to be added to the
log event.
The concept of Layouts still has a place, but it has been moved to be an
implementation detail of the appenders. *ConsoleAppender* accepts a
*LogMessageFormatter* function allowing for formatting of the resulting
messages written to the console. the *ApiAppender* would be an example of an
appender for which layout would be irrelevant.
Finally, notice that all of the appenders provided by this library
automatically persist log messages as structured data, flattening the `message`
field if it is an object. *ConsoleAppender* will write the log message as a
JSON object, promoting fields in the `message` object to top-level fields in
the output. For example:
```typescript
import { logService, ConsoleAppender } from '@jdbernard/logging';
logService.ROOT_LOGGER.appenders.push(new ConsoleAppender('TRACE'));
const logger = logService.getLogger('example');
function someBusinessLogic() {
logger.debug({ method: 'someBusinessLogic', msg: 'Doing some business logic', foo: 'bar' });
}
logger.info('Starting application');
someBusinessLogic();
```
results in the following two events logged as output to the console:
```json
{"timestamp":"2021-09-01T00:00:00.000Z","level":"INFO","scope":"example","message":"Starting application"}
{"timestamp":"2021-09-01T00:00:00.000Z","level":"DEBUG","scope":"example","msg":"Doing some business logic","method":"someBusinessLogic","foo":"bar"}
```
Note that the field name in the structured data for string messages is
"message". In the case of an object message, the fields are used as provided.
Fields defined on the *LogMessage* interface are reserved and should not be
used as keys in the message object (and will be ignored if they are). So, for
example:
```typescript
logger.debug({ level: 'WARN', message: 'Example of ignored fields', timestamp: 'today' });
results in the following event logged as output to the console (note the
ignored `level` and `timestamp` fields from the object):
```json
{"timestamp":"2021-09-01T00:00:00.000Z","level":"DEBUG","scope":"example","message":"Example of ignored fields"}
```
This flattening behavior is implemented in the `flattenMessage` function
exported from the `@jdbernard/logging/log-message.ts` module and is available
for use in custom appender implemetations.
As a final note, the *ConsoleAppender* has some special behavior for the ERROR
level and *stacktrace* field when running in a web browser to take advantage of
the console's built-in. See the documentation for the *ConsoleAppender* for
more information.
## Worked Example
To illustrate the usage of the logging system and the purpose of the
fine-grained control described above, consider the following TypeScript
example:
```typescript
// main.ts -- Entry point of the application
// A console appender is attached to the root logger to log all events at or
// above the INFO level.
logService.ROOT_LOGGER.appenders.push(new ConsoleAppender(LogLevel.INFO));
// An API appender is attached to the root logger to forward all ERROR events
// to a server-side error log collector.
logServive.ROOT_LOGGER.appenders.push(new ApiAppender(
'https://api.example.com/error-logs',
appStore.user.sessionToken,
LogLevel.ERROR));
const appLog = logService.getLogger('app');
appLog.info('Starting application');
// api.ts -- API implementaiton
const apiLog = logService.getLogger('app/api');
// http-client.ts -- HTTP client implementation
const httpLog = logService.getLogger('app/api/http-client');
```
Because different parts of the application use namespaced loggers, we can
dynamically adjust the logging level of different parts of the application
without changing the code. For example, to increase the logging level of the
HTTP client to DEBUG, we can add the following line to the `main.ts` file:
```typescript
logService.getLogger('app/api/http-client').setLevel(LogLevel.DEBUG);
```
Additionally, if, for some reason, we only wanted to forward the ERROR events
from the API namespace to the server-side error log collector, we could attach
the *ApiAppender* to the `app/api` logger instead of the root logger. This
would allow us to log all events from both the API and HTTP client loggers to
the API log collector, but ignore the rest of the application regardless of the
logging level.
[log4j]: https://logging.apache.org/log4j/2.x/
[logback]: https://logback.qos.ch/
[effective logging level]: https://logback.qos.ch/manual/architecture.html#effectiveLevel

BIN
bun.lockb Executable file

Binary file not shown.

54
eslint.config.mjs Normal file
View File

@ -0,0 +1,54 @@
import importPlugin from 'eslint-plugin-import'
import tsParser from '@typescript-eslint/parser'
import eslintJs from '@eslint/js'
import eslintTs from 'typescript-eslint'
const tsFiles = ['src/**/*.ts']
const customTypescriptConfig = {
files: tsFiles,
plugins: {
import: importPlugin,
'import/parsers': tsParser,
},
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
},
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts'],
},
},
rules: {
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single', { avoidEscape: true }],
},
}
const recommendedTypeScriptConfigs = [
...eslintTs.configs.recommended.map((config) => ({
...config,
files: tsFiles,
})),
...eslintTs.configs.stylistic.map((config) => ({
...config,
files: tsFiles,
})),
]
export default [
{
ignores: [
'docs/*',
'build/*',
'lib/*',
'dist/*',
],
}, // global ignores
eslintJs.configs.recommended,
...recommendedTypeScriptConfigs,
customTypescriptConfig,
]

29
package-lock.json generated
View File

@ -1,29 +0,0 @@
{
"name": "@jdbernard/logging",
"version": "1.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jdbernard/logging",
"version": "1.1.4",
"license": "GPL-3.0",
"devDependencies": {
"typescript": "^5.0.4"
}
},
"node_modules/typescript": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz",
"integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=12.20"
}
}
}
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@jdbernard/logging", "name": "@jdbernard/logging",
"version": "1.1.5", "version": "2.0.0",
"description": "Simple Javascript logging service.", "description": "Simple Javascript logging service.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@ -23,6 +23,9 @@
"author": "Jonathan Bernard", "author": "Jonathan Bernard",
"license": "GPL-3.0", "license": "GPL-3.0",
"devDependencies": { "devDependencies": {
"typescript": "^5.0.4" "@typescript-eslint/parser": "^8.19.0",
"eslint-plugin-import": "^2.31.0",
"typescript": "^5.0.4",
"typescript-eslint": "^8.19.0"
} }
} }

View File

@ -1,20 +1,13 @@
import { LogMessage, LogLevel } from './log-message'; import { LogMessage, LogLevel, flattenMessage, FlattenedLogMessage } from './log-message';
import LogAppender from './log-appender'; import { LogAppender } from './log-appender';
interface ApiMessage {
level: string;
message: string;
scope: string;
stacktrace: string;
timestamp: string;
}
export class ApiLogAppender implements LogAppender { export class ApiLogAppender implements LogAppender {
public batchSize = 10; public batchSize = 10;
public minimumTimePassedInSec = 60; public minimumTimePassedInSec = 60;
public maximumTimePassedInSec = 120; public maximumTimePassedInSec = 120;
public threshold = LogLevel.ALL; public threshold = LogLevel.ALL;
private msgBuffer: ApiMessage[] = []; private msgBuffer: FlattenedLogMessage[] = [];
private lastSent = 0; private lastSent = 0;
constructor( constructor(
@ -33,16 +26,7 @@ export class ApiLogAppender implements LogAppender {
return; return;
} }
this.msgBuffer.push({ this.msgBuffer.push(flattenMessage(msg));
level: LogLevel[msg.level],
message:
typeof msg.message === 'string'
? msg.message
: JSON.stringify(msg.message),
scope: msg.scope,
stacktrace: msg.stacktrace,
timestamp: msg.timestamp.toISOString()
});
} }
private doPost() { private doPost() {

View File

@ -1,14 +1,22 @@
/*tslint:disable:no-console*/ import {
import { LogMessage, LogLevel } from './log-message'; flattenMessage,
import LogAppender from './log-appender'; LogLevel,
LogMessage,
LogMessageFormatter,
} from './log-message';
import { LogAppender } from './log-appender';
export class ConsoleLogAppender implements LogAppender { export class ConsoleLogAppender implements LogAppender {
public threshold = LogLevel.ALL; public threshold = LogLevel.ALL;
public formatter: LogMessageFormatter = flattenMessage
constructor(threshold?: LogLevel) { constructor(threshold?: LogLevel, formatter?: LogMessageFormatter) {
if (threshold) { if (threshold) {
this.threshold = threshold; this.threshold = threshold;
} }
if (formatter) {
this.formatter = formatter;
}
} }
public appendMessage(msg: LogMessage): void { public appendMessage(msg: LogMessage): void {
@ -42,12 +50,12 @@ export class ConsoleLogAppender implements LogAppender {
break; break;
} }
if (msg.error) { const strMsg = this.formatter(msg);
logMethod(`[${msg.scope}]:`, msg.message, msg.error);
} else if (msg.stacktrace) { if (msg.error || msg.stacktrace) {
logMethod(`[${msg.scope}]:`, msg.message, msg.stacktrace); logMethod(strMsg, msg.error ?? msg.stacktrace);
} else { } else {
logMethod(`[${msg.scope}]:`, msg.message); logMethod(strMsg);
} }
} }
} }

View File

@ -1,5 +1,8 @@
import { LogLevel, LogMessage } from './log-message'; import { LogLevel, LogMessage } from './log-message';
export default interface LogAppender {
export interface LogAppender {
threshold: LogLevel; threshold: LogLevel;
appendMessage(message: LogMessage): void; appendMessage(message: LogMessage): void;
} }
export default LogAppender;

View File

@ -1,3 +1,5 @@
import { omit } from './util'
export enum LogLevel { export enum LogLevel {
ALL = 0, ALL = 0,
TRACE, TRACE,
@ -6,12 +8,15 @@ export enum LogLevel {
INFO, INFO,
WARN, WARN,
ERROR, ERROR,
FATAL FATAL,
} }
export function parseLogLevel(str: string, defaultLevel = LogLevel.INFO): LogLevel { export function parseLogLevel(
if (Object.prototype.hasOwnProperty.call(LogLevel, str)) { str: string,
return LogLevel[<any>str] as unknown as LogLevel; defaultLevel = LogLevel.INFO,
): LogLevel {
if (str in LogLevel) {
return LogLevel[str as keyof typeof LogLevel] as LogLevel;
} else { } else {
return defaultLevel; return defaultLevel;
} }
@ -20,10 +25,60 @@ export function parseLogLevel(str: string, defaultLevel = LogLevel.INFO): LogLev
export interface LogMessage { export interface LogMessage {
scope: string; scope: string;
level: LogLevel; level: LogLevel;
message: string | object; message: string | Record<string, unknown>;
stacktrace: string; stacktrace?: string;
error?: Error; error?: Error;
timestamp: Date; timestamp: Date;
} }
export type FlattenedLogMessage = Record<string, unknown>;
/**
* Flatten a log message to a plain object. The *message* field can be either a
* string or an object. In the case of an object message, the LogMessage should
* be flattened before being emitted by an appender, promoting the object's
* fields to the top level of the message. Fields defined on the *LogMessage*
* interface are reserved and should not be used as keys in the message object
* (and will be ignored if they are).
*
* So, for example:
*
* ```typescript
* const logger = logService.getLogger('example');
* logger.info({ foo: 'bar', baz: 'qux', timestamp: 'today', level: LogLevel.WARN });
* ```
*
* Should result after flattening in a structured log message like:
* ```json
* {"scope":"example","level":"INFO","foo":"bar","baz":"qux","timestamp":"2020-01-01T00:00:00.000Z"}
* ```
*/
export function flattenMessage(msg: LogMessage): FlattenedLogMessage {
if (typeof msg.message === 'string') {
return { ...msg, level: LogLevel[msg.level] };
} else {
const { message, ...rest } = msg;
return {
...omit(message, [
'scope',
'level',
'stacktrace',
'error',
'timestamp',
]),
...rest,
level: LogLevel[msg.level],
};
}
}
export type LogMessageFormatter = (msg: LogMessage) => string | FlattenedLogMessage;
export function structuredLogMessageFormatter(msg: LogMessage): string {
return JSON.stringify(flattenMessage(msg));
}
export function simpleTextLogMessageFormatter(msg: LogMessage): string {
return `[${msg.scope}] - ${msg.level}: ${msg.message}`;
}
export default LogMessage; export default LogMessage;

View File

@ -1,10 +1,16 @@
import { LogLevel } from './log-message'; import { LogLevel } from './log-message';
import Logger from './logger'; import { Logger } from './logger';
const ROOT_LOGGER_NAME = 'ROOT'; const ROOT_LOGGER_NAME = 'ROOT';
/**
* Service for managing loggers. A LogService instance defines
* the service context for a set of loggers. Typically there is only one
* LogService instance per application, the one exported as default from this
* module.
*/
export class LogService { export class LogService {
private loggers: { [key: string]: Logger }; private loggers: Record<string, Logger>;
public get ROOT_LOGGER() { public get ROOT_LOGGER() {
return this.loggers[ROOT_LOGGER_NAME]; return this.loggers[ROOT_LOGGER_NAME];
@ -15,10 +21,38 @@ export class LogService {
this.loggers[ROOT_LOGGER_NAME] = new Logger( this.loggers[ROOT_LOGGER_NAME] = new Logger(
ROOT_LOGGER_NAME, ROOT_LOGGER_NAME,
undefined, undefined,
LogLevel.ALL LogLevel.ALL,
); );
} }
/**
* Get a logger by name. If the logger does not exist, it will be created.
* Loggers are hierarchical, with the heirarchy defined by the logger name.
* When creating a new logger, the parent logger is determined by the longest
* existing logger name that is a prefix of the new logger name. For example,
* if a logger with the name "foo" already exists, any subsequent loggers
* with the names "foo.bar", "foo/bar", "foolish.choice", etc. will be
* created as children of the "foo" logger.
*
* As another example, given the following invocations:
*
* ```typescript
* logService.getLogger('foo');
* logService.getLogger('foo.bar');
* logService.getLogger('foo.bar.baz');
* logService.getLogger('foo.qux');
* ```
*
* will result in the following logging hierarchy:
*
* foo
* foo.bar
* foo.bar.baz
* foo.qux
*
* See the Logger class for details on how loggers are used and the
* implications of the logger hierarchy.
*/
public getLogger(name: string, threshold?: LogLevel): Logger { public getLogger(name: string, threshold?: LogLevel): Logger {
if (this.loggers[name]) { if (this.loggers[name]) {
return this.loggers[name]; return this.loggers[name];
@ -30,7 +64,7 @@ export class LogService {
.filter((n: string) => name.startsWith(n)) .filter((n: string) => name.startsWith(n))
.reduce( .reduce(
(acc: string, cur: string) => (acc.length > cur.length ? acc : cur), (acc: string, cur: string) => (acc.length > cur.length ? acc : cur),
'' '',
); );
if (parentLoggerName) { if (parentLoggerName) {

View File

@ -1,16 +1,52 @@
import { LogMessage, LogLevel } from './log-message'; import { LogLevel, LogMessage } from './log-message';
import LogAppender from './log-appender'; import { LogAppender } from './log-appender';
export type DeferredMsg = () => string | object; export type DeferredMsg = () => string | Record<string, unknown>;
export type MessageType = string | DeferredMsg | object; export type MessageType = string | DeferredMsg | Record<string, unknown>;
export function isDeferredMsg(msg: MessageType): msg is DeferredMsg {
return typeof msg === 'function';
}
/**
* Logger class for logging messages.
*
* Loggers are used to log messages at different levels. The levels are, in
* order of increasing severity: TRACE, DEBUG, LOG, INFO, WARN, ERROR, FATAL.
* Log messages can be logged at a specific level by calling the corresponding
* method on the logger instance. For example, to log a message at the INFO
* level, call the *info* method on the logger.
*
* Loggers have a threshold level, which is the minimum level of log message
* that will be logged by the logger. Log messages with a level below the
* threshold will be ignored. The threshold level can be set when creating a
* logger, or by calling the *setThreshold* method on an existing logger. If a
* threshold is not set, the logger will use the threshold of its parent
* logger.
*
* Loggers are hierarchical, with the hierarchy defined by the logger name.
* The heirarchy is tracked by the children: child loggers have a link to their
* parents, while parent loggers do not have a list of their children.
*
* When a log message is logged by a logger, it is sent to all of the appenders
* attached to the logger and to the parent logger. Appenders can be attached
* to any Logger instance, but, because of this upwards propogation, appenders
* are usually attached to the root logger, which is an ancestor of all loggers
* created by the same LogService instance.
*
* Loggers are typically created and managed by a LogService instance. The
* *LogService#getLogger* method is used to get a logger by name, creating it
* if necessary. When creating a new logger, the parent logger is determined by
* the longest existing logger name that is a prefix of the new logger name.
* For more details, see *LogService#getLogger*.
*/
export class Logger { export class Logger {
public appenders: LogAppender[] = []; public appenders: LogAppender[] = [];
public constructor( public constructor(
public readonly name: string, public readonly name: string,
private parentLogger?: Logger, private parentLogger?: Logger,
public threshold?: LogLevel public threshold?: LogLevel,
) {} ) {}
public createChildLogger(name: string, threshold?: LogLevel): Logger { public createChildLogger(name: string, threshold?: LogLevel): Logger {
@ -20,7 +56,7 @@ export class Logger {
public doLog( public doLog(
level: LogLevel, level: LogLevel,
message: Error | MessageType, message: Error | MessageType,
stacktrace?: string stacktrace?: string,
): void { ): void {
if (level < this.getEffectiveThreshold()) { if (level < this.getEffectiveThreshold()) {
return; return;
@ -31,20 +67,20 @@ export class Logger {
level, level,
message: '', message: '',
stacktrace: '', stacktrace: '',
timestamp: new Date() timestamp: new Date(),
}; };
if (message === undefined || message === null) { if (message === undefined || message === null) {
logMsg.message = message; logMsg.message = message;
logMsg.stacktrace = stacktrace == null ? '' : stacktrace; logMsg.stacktrace = stacktrace ?? '';
} else if ((message as DeferredMsg).call !== undefined) {
logMsg.message = (message as DeferredMsg)();
logMsg.stacktrace = stacktrace == null ? '' : stacktrace;
} else if (message instanceof Error) { } else if (message instanceof Error) {
const error = message as Error; const error = message as Error;
logMsg.error = error; logMsg.error = error;
logMsg.message = `${error.name}: ${error.message}`; logMsg.message = `${error.name}: ${error.message}`;
logMsg.stacktrace = error.stack == null ? '' : error.stack; logMsg.stacktrace = stacktrace ?? error.stack ?? '';
} else if (isDeferredMsg(message)) {
logMsg.message = message();
logMsg.stacktrace = stacktrace == null ? '' : stacktrace;
} else { } else {
// string | object // string | object
logMsg.message = message; logMsg.message = message;
@ -83,7 +119,7 @@ export class Logger {
} }
protected sendToAppenders(logMsg: LogMessage) { protected sendToAppenders(logMsg: LogMessage) {
this.appenders.forEach(app => { this.appenders.forEach((app) => {
app.appendMessage(logMsg); app.appendMessage(logMsg);
}); });

12
src/util.ts Normal file
View File

@ -0,0 +1,12 @@
export function omit(
obj: Record<string, unknown>,
keys: string[],
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const key in obj) {
if (!keys.includes(key)) {
result[key] = obj[key];
}
}
return result;
}

View File

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "commonjs",
"target": "es6", "target": "es2016",
"declaration": true, "declaration": true,
"outDir": "./dist", "outDir": "./dist",
"strict": true, "strict": true,