30 Commits

Author SHA1 Message Date
mahseiah_ai ba52c2083a ci: switch to bun runner, remove setup-bun steps
Validate / test (pull_request) Successful in 32s
Validate / test (push) Successful in 8s
2026-05-05 16:01:57 -04:00
mahseiah_ai c38683bf53 ci: replace oven-sh/setup-bun with curl install for Gitea Actions compatibility
Validate / test (pull_request) Failing after 19s
The  action is a GitHub Marketplace action
that doesn't exist on self-hosted Gitea instances. Replace it with
the standard curl-based Bun installation method and add ~/.bun/bin
to GITHUB_PATH.
2026-05-05 15:54:36 -04:00
mahseiah_ai c7d618ad92 test: add comprehensive unit tests for all appenders and logger
Validate / test (pull_request) Failing after 21s
Tests added:
- log-service.test.ts: hierarchical logger creation and threshold propagation
- logger.test.ts: threshold inheritance, message propagation, falsy threshold
  bug coverage (LogLevel.ALL = 0), deferred messages, Error handling
- log-message.test.ts: parseLogLevel parsing, flattenMessage object/string modes
- console-log-appender.test.ts: threshold, formatter, all-level routing
- buffer-log-appender.test.ts: buffer append, threshold filtering, clearBuffer
- api-log-appender.test.ts: configuration defaults, threshold, auth token

Also fixes src/index.ts to export BufferLogAppender which was previously
missing from the barrel export.
2026-05-05 15:07:36 -04:00
mahseiah_ai e76f408f80 ci: add Gitea Actions workflow for test validation
- Runs on PRs targeting main and pushes to main
- Uses oven-sh/setup-bun@v2 to provision Bun
- Runs bun test v1.3.12 (700fc117) to validate the library
2026-05-05 14:59:59 -04:00
mahseiah_ai b7e78bbb9d build: add test and test:coverage scripts to package.json
- bun test v1.3.12 (700fc117) runs bun test (discovers tests in test/ directory)
- bun test v1.3.12 (700fc117) runs tests with coverage reporting
2026-05-05 14:59:24 -04:00
mahseiah_ai 31cf9ceef7 test: stand up test scaffolding with bun test
- Create test/ directory with a minimal LogService test
- Prove out the pattern: bun test natively discovers and runs tests
  in the test/ directory using ESM-style imports from ../src/
2026-05-05 14:58:56 -04:00
jdb f9cb676b46 Properly set default thresholds on log appenders. 2026-05-05 07:50:47 -05:00
jdb 4dcc4fad25 Add BufferLogAppender
Sometimes it is useful to capture log messages in a way that is easy for
the running program to introspect or manually handle.
*BufferLogAppender* captures the logs in a simple array.

Be careful, there is no default flush/clear mechanism that runs
automatically. Users of BufferLogAppender should take care to
render/handle log messages and periodically call `clearBuffer` on the
*BufferLogAppender* instance to avoid the buffer array growing without
limit.
2026-05-05 06:25:09 -05:00
jdb 895a8c42ca ConsoleLogAppender logs TRACE messages with console.log
console.trace generates and prints a full stacktrace for every log
message. This is not what TRACE means in this library. The TRACE level
is intended for logs that are more verbose than you typically want to
log and should only be turned on when you are trying to trace the
detailed behavior of the logged functionality. Printing the full stack
trace with every message makes an already verbose setting exponentially
worse.
2026-01-09 19:23:38 -06:00
jdb 642078e728 Fix all sources to comply with eslint rules introduced in 2.0.0 (no logic changes). 2026-01-09 19:19:15 -06:00
jdb 8b0acc6f40 Add fixed tool versions. 2026-01-09 19:18:56 -06:00
jdb eb89d1da71 Tweak: don't overshadow msg in inner context. 2026-01-09 18:46:30 -06:00
jdb 79b9fe20ac Correct ConsoleLogAppender to use msg instead of message. 2025-12-16 13:20:36 -06:00
jdb 47fa404914 Make ERROR and FATAL levels use the console.error function when logging to a JS console. 2025-12-15 12:26:49 -06:00
jdb 4ddeeab228 Clarify documentation for flattenMessage. 2025-12-15 12:26:09 -06:00
jdb f33ca24b53 timestamp -> ts; error -> err 2025-07-14 15:38:40 -05:00
jdb 318135e82b Update README based on naming convention change. 2025-06-26 10:58:27 -05:00
jdb ef2b0ed750 Change the naming convention for LogMessage internal message field. 2025-06-26 10:44:57 -05:00
jdb f21cce9944 Update eslint config. 2025-01-07 09:40:09 -06:00
jdb a89a41520c ConsoleLogAppender writes a human-readable summary when logging structured data.
Taking advantage of the new LogMessageFormatter return type, the
ConsoleLogAppender logs the formatted message as-is if it is a string,
but when processing structured data inserts a string summary consisting
of the message level, scope, and message summary or method. The full
object is still logged to the console as well for inspection.
2025-01-07 09:30:21 -06:00
jdb 6b4173d636 .gitignore for built packages created with npm pack 2025-01-07 08:53:25 -06:00
jdb 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
jdb 13941840ce Move back to npm (bun does not package correctly). 2025-01-02 22:53:10 -06:00
jdb 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
jdb 4a9f582ad8 Migrate to bun and eslint. 2025-01-02 17:01:39 -06:00
jdb 8418b242c3 Replace Axios with fetch API. 2023-05-29 19:20:12 -05:00
jdb f0944d0d7e Update Axios for security fixes. 2023-05-29 19:03:55 -05:00
jdb f75edbc22a Fix NPM packaging. 2021-08-16 23:03:57 -05:00
jdb b2d1b71a52 Remove erroneously committed test code. 2021-07-23 00:15:32 -05:00
jdb c8ed8b61da Bump version to republish with built artifacts. 2021-07-23 00:12:23 -05:00
38 changed files with 1107 additions and 579 deletions
+20
View File
@@ -0,0 +1,20 @@
name: Validate
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: bun
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install
- name: Run tests
run: bun test
+3
View File
@@ -1,2 +1,5 @@
node_modules/
dist/
*.sw?
.lvimrc
jdbernard-logging-*.tgz
+166
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 *msg* 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 `msg`
field if it is an object. *ConsoleAppender* will write the log message as a
JSON object, promoting fields in the `msg` 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","msg":"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
"msg". 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 msg object (and will be ignored if they are). So, for
example:
```typescript
logger.debug({ level: 'WARN', msg: '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","msg":"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
Executable
BIN
View File
Binary file not shown.
-18
View File
@@ -1,18 +0,0 @@
import { LogMessage, LogLevel } from './log-message';
import LogAppender from './log-appender';
export declare class ApiLogAppender implements LogAppender {
readonly apiEndpoint: string;
authToken?: string | undefined;
batchSize: number;
minimumTimePassedInSec: number;
maximumTimePassedInSec: number;
threshold: LogLevel;
private http;
private msgBuffer;
private lastSent;
constructor(apiEndpoint: string, authToken?: string | undefined, threshold?: LogLevel);
appendMessage(msg: LogMessage): void;
private doPost;
private checkPost;
}
export default ApiLogAppender;
-60
View File
@@ -1,60 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ApiLogAppender = void 0;
const axios_1 = require("axios");
const log_message_1 = require("./log-message");
class ApiLogAppender {
constructor(apiEndpoint, authToken, threshold) {
this.apiEndpoint = apiEndpoint;
this.authToken = authToken;
this.batchSize = 10;
this.minimumTimePassedInSec = 60;
this.maximumTimePassedInSec = 120;
this.threshold = log_message_1.LogLevel.ALL;
this.http = axios_1.default.create();
this.msgBuffer = [];
this.lastSent = 0;
this.checkPost = () => {
const now = Date.now();
const min = this.lastSent + this.minimumTimePassedInSec * 1000;
const max = this.lastSent + this.maximumTimePassedInSec * 1000;
if ((this.msgBuffer.length >= this.batchSize && min < now) ||
(this.msgBuffer.length > 0 && max < now)) {
this.doPost();
}
setTimeout(this.checkPost, Math.max(10000, this.minimumTimePassedInSec * 1000));
};
setTimeout(this.checkPost, 1000);
if (threshold) {
this.threshold = threshold;
}
}
appendMessage(msg) {
if (this.threshold && msg.level < this.threshold) {
return;
}
this.msgBuffer.push({
level: log_message_1.LogLevel[msg.level],
message: typeof msg.message === 'string'
? msg.message
: JSON.stringify(msg.message),
scope: msg.scope,
stacktrace: msg.stacktrace,
timestamp: msg.timestamp.toISOString()
});
}
doPost() {
if (this.msgBuffer.length > 0 && this.authToken) {
this.http.post(this.apiEndpoint, this.msgBuffer, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.authToken}`
}
});
this.lastSent = Date.now();
this.msgBuffer = [];
}
}
}
exports.ApiLogAppender = ApiLogAppender;
exports.default = ApiLogAppender;
-8
View File
@@ -1,8 +0,0 @@
import { LogMessage, LogLevel } from './log-message';
import LogAppender from './log-appender';
export declare class ConsoleLogAppender implements LogAppender {
threshold: LogLevel;
constructor(threshold?: LogLevel);
appendMessage(msg: LogMessage): void;
}
export default ConsoleLogAppender;
-54
View File
@@ -1,54 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ConsoleLogAppender = void 0;
/*tslint:disable:no-console*/
const log_message_1 = require("./log-message");
class ConsoleLogAppender {
constructor(threshold) {
this.threshold = log_message_1.LogLevel.ALL;
if (threshold) {
this.threshold = threshold;
}
}
appendMessage(msg) {
if (this.threshold && msg.level < this.threshold) {
return;
}
let logMethod = console.log;
switch (msg.level) {
case log_message_1.LogLevel.ALL:
logMethod = console.log;
break;
case log_message_1.LogLevel.TRACE:
logMethod = console.log;
break;
case log_message_1.LogLevel.DEBUG:
logMethod = console.debug;
break;
case log_message_1.LogLevel.LOG:
logMethod = console.log;
break;
case log_message_1.LogLevel.INFO:
logMethod = console.info;
break;
case log_message_1.LogLevel.WARN:
logMethod = console.warn;
break;
case log_message_1.LogLevel.ERROR:
case log_message_1.LogLevel.FATAL:
logMethod = console.trace;
break;
}
if (msg.error) {
logMethod(`[${msg.scope}]:`, msg.message, msg.error);
}
else if (msg.stacktrace) {
logMethod(`[${msg.scope}]:`, msg.message, msg.stacktrace);
}
else {
logMethod(`[${msg.scope}]:`, msg.message);
}
}
}
exports.ConsoleLogAppender = ConsoleLogAppender;
exports.default = ConsoleLogAppender;
-6
View File
@@ -1,6 +0,0 @@
export * from './log-message';
export * from './log-appender';
export * from './log-service';
export * from './console-log-appender';
export * from './api-log-appender';
export * from './logger';
-18
View File
@@ -1,18 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !exports.hasOwnProperty(p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./log-message"), exports);
__exportStar(require("./log-appender"), exports);
__exportStar(require("./log-service"), exports);
__exportStar(require("./console-log-appender"), exports);
__exportStar(require("./api-log-appender"), exports);
__exportStar(require("./logger"), exports);
-5
View File
@@ -1,5 +0,0 @@
import { LogLevel, LogMessage } from './log-message';
export default interface LogAppender {
threshold: LogLevel;
appendMessage(message: LogMessage): void;
}
-2
View File
@@ -1,2 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
-19
View File
@@ -1,19 +0,0 @@
export declare enum LogLevel {
ALL = 0,
TRACE = 1,
DEBUG = 2,
LOG = 3,
INFO = 4,
WARN = 5,
ERROR = 6,
FATAL = 7
}
export interface LogMessage {
scope: string;
level: LogLevel;
message: string | object;
stacktrace: string;
error?: Error;
timestamp: Date;
}
export default LogMessage;
-14
View File
@@ -1,14 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LogLevel = void 0;
var LogLevel;
(function (LogLevel) {
LogLevel[LogLevel["ALL"] = 0] = "ALL";
LogLevel[LogLevel["TRACE"] = 1] = "TRACE";
LogLevel[LogLevel["DEBUG"] = 2] = "DEBUG";
LogLevel[LogLevel["LOG"] = 3] = "LOG";
LogLevel[LogLevel["INFO"] = 4] = "INFO";
LogLevel[LogLevel["WARN"] = 5] = "WARN";
LogLevel[LogLevel["ERROR"] = 6] = "ERROR";
LogLevel[LogLevel["FATAL"] = 7] = "FATAL";
})(LogLevel = exports.LogLevel || (exports.LogLevel = {}));
-10
View File
@@ -1,10 +0,0 @@
import { LogLevel } from './log-message';
import Logger from './logger';
export declare class LogService {
private loggers;
get ROOT_LOGGER(): Logger;
constructor();
getLogger(name: string, threshold?: LogLevel): Logger;
}
export declare const logService: LogService;
export default logService;
-35
View File
@@ -1,35 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.logService = exports.LogService = void 0;
const log_message_1 = require("./log-message");
const logger_1 = require("./logger");
const ROOT_LOGGER_NAME = 'ROOT';
class LogService {
constructor() {
this.loggers = {};
this.loggers[ROOT_LOGGER_NAME] = new logger_1.default(ROOT_LOGGER_NAME, undefined, log_message_1.LogLevel.ALL);
}
get ROOT_LOGGER() {
return this.loggers[ROOT_LOGGER_NAME];
}
getLogger(name, threshold) {
if (this.loggers[name]) {
return this.loggers[name];
}
let parentLogger;
const parentLoggerName = Object.keys(this.loggers)
.filter((n) => name.startsWith(n))
.reduce((acc, cur) => (acc.length > cur.length ? acc : cur), '');
if (parentLoggerName) {
parentLogger = this.loggers[parentLoggerName];
}
else {
parentLogger = this.ROOT_LOGGER;
}
this.loggers[name] = parentLogger.createChildLogger(name, threshold);
return this.loggers[name];
}
}
exports.LogService = LogService;
exports.logService = new LogService();
exports.default = exports.logService;
-23
View File
@@ -1,23 +0,0 @@
import { LogMessage, LogLevel } from './log-message';
import LogAppender from './log-appender';
export declare type DeferredMsg = () => string | object;
export declare type MessageType = string | DeferredMsg | object;
export declare class Logger {
readonly name: string;
private parentLogger?;
threshold?: LogLevel | undefined;
appenders: LogAppender[];
constructor(name: string, parentLogger?: Logger | undefined, threshold?: LogLevel | undefined);
createChildLogger(name: string, threshold?: LogLevel): Logger;
doLog(level: LogLevel, message: Error | MessageType, stacktrace?: string): void;
trace(message: Error | MessageType, stacktrace?: string): void;
debug(message: Error | MessageType, stacktrace?: string): void;
log(message: MessageType, stacktrace?: string): void;
info(message: MessageType, stacktrace?: string): void;
warn(message: MessageType, stacktrace?: string): void;
error(message: Error | MessageType, stacktrace?: string): void;
fatal(message: Error | MessageType, stacktrace?: string): void;
protected sendToAppenders(logMsg: LogMessage): void;
protected getEffectiveThreshold(): LogLevel;
}
export default Logger;
-88
View File
@@ -1,88 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Logger = void 0;
const log_message_1 = require("./log-message");
class Logger {
constructor(name, parentLogger, threshold) {
this.name = name;
this.parentLogger = parentLogger;
this.threshold = threshold;
this.appenders = [];
}
createChildLogger(name, threshold) {
return new Logger(name, this, threshold);
}
doLog(level, message, stacktrace) {
if (level < this.getEffectiveThreshold()) {
return;
}
const logMsg = {
scope: this.name,
level,
message: '',
stacktrace: '',
timestamp: new Date()
};
if (message === undefined || message === null) {
logMsg.message = message;
logMsg.stacktrace = stacktrace == null ? '' : stacktrace;
}
else if (message.call !== undefined) {
logMsg.message = message();
logMsg.stacktrace = stacktrace == null ? '' : stacktrace;
}
else if (message instanceof Error) {
const error = message;
logMsg.error = error;
logMsg.message = `${error.name}: ${error.message}`;
logMsg.stacktrace = error.stack == null ? '' : error.stack;
}
else {
// string | object
logMsg.message = message;
logMsg.stacktrace = stacktrace == null ? '' : stacktrace;
}
this.sendToAppenders(logMsg);
}
trace(message, stacktrace) {
this.doLog(log_message_1.LogLevel.TRACE, message, stacktrace);
}
debug(message, stacktrace) {
this.doLog(log_message_1.LogLevel.DEBUG, message, stacktrace);
}
log(message, stacktrace) {
this.doLog(log_message_1.LogLevel.LOG, message, stacktrace);
}
info(message, stacktrace) {
this.doLog(log_message_1.LogLevel.INFO, message, stacktrace);
}
warn(message, stacktrace) {
this.doLog(log_message_1.LogLevel.WARN, message, stacktrace);
}
error(message, stacktrace) {
this.doLog(log_message_1.LogLevel.ERROR, message, stacktrace);
}
fatal(message, stacktrace) {
this.doLog(log_message_1.LogLevel.FATAL, message, stacktrace);
}
sendToAppenders(logMsg) {
this.appenders.forEach(app => {
app.appendMessage(logMsg);
});
if (this.parentLogger) {
this.parentLogger.sendToAppenders(logMsg);
}
}
getEffectiveThreshold() {
if (this.threshold) {
return this.threshold;
}
if (this.parentLogger) {
return this.parentLogger.getEffectiveThreshold();
}
// should never happen (root logger should always have a threshold
return log_message_1.LogLevel.ALL;
}
}
exports.Logger = Logger;
exports.default = Logger;
+68
View File
@@ -0,0 +1,68 @@
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 }],
semi: ['error', 'never'],
'@typescript-eslint/no-unused-vars': ['error', {
'args': 'all',
'argsIgnorePattern': '^_',
'caughtErrorsIgnorePattern': '^_',
'destructuredArrayIgnorePattern': '^_',
'varsIgnorePattern': '^_',
}],
},
}
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,
{
rules: {
quotes: ['error', 'single', { avoidEscape: true }],
semi: ['error', 'never'],
},
},
]
+3
View File
@@ -0,0 +1,3 @@
[tools]
node = "latest"
deno = "none"
-43
View File
@@ -1,43 +0,0 @@
{
"name": "js-log",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"axios": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz",
"integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==",
"requires": {
"follow-redirects": "1.5.10"
}
},
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
},
"follow-redirects": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
"requires": {
"debug": "=3.1.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"typescript": {
"version": "3.9.7",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz",
"integrity": "sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==",
"dev": true
}
}
}
+14 -8
View File
@@ -1,11 +1,17 @@
{
"name": "@jdbernard/logging",
"version": "1.1.0",
"version": "2.3.3",
"description": "Simple Javascript logging service.",
"main": "src/index.ts",
"module": "dist/index.js",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"build": "bunx tsc",
"prepare": "npm run build",
"test": "bun test",
"test:coverage": "bun test --coverage"
},
"repository": {
"type": "git",
@@ -18,9 +24,9 @@
"author": "Jonathan Bernard",
"license": "GPL-3.0",
"devDependencies": {
"typescript": "^3.9.7"
},
"dependencies": {
"axios": "^0.19.2"
"@typescript-eslint/parser": "^8.19.0",
"eslint-plugin-import": "^2.31.0",
"typescript": "^5.0.4",
"typescript-eslint": "^8.19.0"
}
}
+26 -44
View File
@@ -1,83 +1,65 @@
import Axios from 'axios';
import { LogMessage, LogLevel, flattenMessage, FlattenedLogMessage } from './log-message'
import { LogAppender } from './log-appender'
import { LogMessage, LogLevel } from './log-message';
import LogAppender from './log-appender';
interface ApiMessage {
level: string;
message: string;
scope: string;
stacktrace: string;
timestamp: string;
}
export class ApiLogAppender implements LogAppender {
public batchSize = 10;
public minimumTimePassedInSec = 60;
public maximumTimePassedInSec = 120;
public threshold = LogLevel.ALL;
public batchSize = 10
public minimumTimePassedInSec = 60
public maximumTimePassedInSec = 120
public threshold: LogLevel
private http = Axios.create();
private msgBuffer: ApiMessage[] = [];
private lastSent = 0;
private msgBuffer: FlattenedLogMessage[] = []
private lastSent = 0
constructor(
public readonly apiEndpoint: string,
public authToken?: string,
threshold?: LogLevel
) {
setTimeout(this.checkPost, 1000);
if (threshold) {
this.threshold = threshold;
}
setTimeout(this.checkPost, 1000)
this.threshold = threshold ?? LogLevel.ALL
}
public appendMessage(msg: LogMessage): void {
if (this.threshold && msg.level < this.threshold) {
return;
return
}
this.msgBuffer.push({
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()
});
this.msgBuffer.push(flattenMessage(msg))
}
private doPost() {
if (this.msgBuffer.length > 0 && this.authToken) {
this.http.post(this.apiEndpoint, this.msgBuffer, {
fetch(this.apiEndpoint, {
method: 'POST',
mode: 'cors',
body: JSON.stringify(this.msgBuffer),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.authToken}`
}
});
})
this.lastSent = Date.now();
this.msgBuffer = [];
this.lastSent = Date.now()
this.msgBuffer = []
}
}
private checkPost = () => {
const now = Date.now();
const min = this.lastSent + this.minimumTimePassedInSec * 1000;
const max = this.lastSent + this.maximumTimePassedInSec * 1000;
const now = Date.now()
const min = this.lastSent + this.minimumTimePassedInSec * 1000
const max = this.lastSent + this.maximumTimePassedInSec * 1000
if (
(this.msgBuffer.length >= this.batchSize && min < now) ||
(this.msgBuffer.length > 0 && max < now)
) {
this.doPost();
this.doPost()
}
setTimeout(
this.checkPost,
Math.max(10000, this.minimumTimePassedInSec * 1000)
);
};
)
}
}
export default ApiLogAppender;
export default ApiLogAppender
+21
View File
@@ -0,0 +1,21 @@
import type { LogAppender } from './log-appender'
import { LogLevel, type LogMessage } from './log-message'
export class BufferLogAppender implements LogAppender {
public threshold: LogLevel
public buffer: LogMessage[]
constructor(buffer?: LogMessage[], threshold?: LogLevel) {
this.buffer = buffer ?? []
this.threshold = threshold ?? LogLevel.ALL
}
public appendMessage(msg: LogMessage): void {
if (this.threshold && msg.level < this.threshold) return
else this.buffer.push(msg)
}
public clearBuffer(): void {
this.buffer.length = 0
}
}
+59 -31
View File
@@ -1,55 +1,83 @@
/*tslint:disable:no-console*/
import { LogMessage, LogLevel } from './log-message';
import LogAppender from './log-appender';
import {
flattenMessage,
LogLevel,
LogMessage,
LogMessageFormatter,
} from './log-message'
import { LogAppender } from './log-appender'
/**
* A log appender that writes log messages to the console. The behavior of the
* log appender can be configured with a threshold level and a message
* formatter.
*
* When the message formatter returns a string value, that value is logged to
* the console as-is. When the message formatter returns an object, a summary
* string is logged to the console, followed by the object itself. This allows
* logs to be easily read in the console, while still providing the structured
* data for inspection in the browser's developer tools.
*/
export class ConsoleLogAppender implements LogAppender {
public threshold = LogLevel.ALL;
public threshold: LogLevel
public formatter: LogMessageFormatter
constructor(threshold?: LogLevel) {
if (threshold) {
this.threshold = threshold;
}
constructor(threshold?: LogLevel, formatter?: LogMessageFormatter) {
this.threshold = threshold ?? LogLevel.ALL
this.formatter = formatter ?? flattenMessage
}
public appendMessage(msg: LogMessage): void {
if (this.threshold && msg.level < this.threshold) {
return;
return
}
let logMethod = console.log;
let logMethod = console.log
switch (msg.level) {
case LogLevel.ALL:
logMethod = console.log;
break;
case LogLevel.TRACE:
logMethod = console.log;
break;
case LogLevel.DEBUG:
logMethod = console.debug;
break;
case LogLevel.LOG:
logMethod = console.log;
break;
// console.trace generates and prints a stack trace attached to the
// logged message. This is not really what we want for TRACE level
// messages as it just makes things more verbose needlessly, so these
// all log to console.log
logMethod = console.log
break
case LogLevel.DEBUG:
logMethod = console.debug
break
case LogLevel.INFO:
logMethod = console.info;
break;
logMethod = console.info
break
case LogLevel.WARN:
logMethod = console.warn;
break;
logMethod = console.warn
break
case LogLevel.ERROR:
case LogLevel.FATAL:
logMethod = console.trace;
break;
logMethod = console.error
break
}
if (msg.error) {
logMethod(`[${msg.scope}]:`, msg.message, msg.error);
} else if (msg.stacktrace) {
logMethod(`[${msg.scope}]:`, msg.message, msg.stacktrace);
const fmtMsg = this.formatter(msg)
if (typeof fmtMsg === 'string') {
if (msg.err || msg.stacktrace) {
logMethod(fmtMsg, msg.err ?? msg.stacktrace)
} else {
logMethod(`[${msg.scope}]:`, msg.message);
logMethod(fmtMsg)
}
} else {
const { msg: innerMsg, _err, _stacktrace, ...rest } = fmtMsg
const summary = `${LogLevel[msg.level]} -- ${msg.scope}: ${
innerMsg ?? fmtMsg.method
}\n`
if (msg.err || msg.stacktrace) {
logMethod(summary, msg.err ?? msg.stacktrace, rest)
} else {
logMethod(summary, rest)
}
}
}
}
export default ConsoleLogAppender;
export default ConsoleLogAppender
+7 -6
View File
@@ -1,6 +1,7 @@
export * from './log-message';
export * from './log-appender';
export * from './log-service';
export * from './console-log-appender';
export * from './api-log-appender';
export * from './logger';
export * from './log-message'
export * from './log-appender'
export * from './log-service'
export * from './console-log-appender'
export * from './api-log-appender'
export * from './buffer-log-appender'
export * from './logger'
+5 -2
View File
@@ -1,5 +1,8 @@
import { LogLevel, LogMessage } from './log-message';
export default interface LogAppender {
import { LogLevel, LogMessage } from './log-message'
export interface LogAppender {
threshold: LogLevel;
appendMessage(message: LogMessage): void;
}
export default LogAppender
+70 -13
View File
@@ -1,3 +1,5 @@
import { omit } from './util'
export enum LogLevel {
ALL = 0,
TRACE,
@@ -6,27 +8,82 @@ export enum LogLevel {
INFO,
WARN,
ERROR,
FATAL
FATAL,
}
const kv = 'WARN';
const TEST = LogLevel[kv];
export function parseLogLevel(str: string, defaultLevel = LogLevel.INFO): LogLevel {
if (Object.prototype.hasOwnProperty.call(LogLevel, str)) {
return LogLevel[<any>str] as unknown as LogLevel;
export function parseLogLevel(
str: string,
defaultLevel = LogLevel.INFO,
): LogLevel {
if (str in LogLevel) {
return LogLevel[str as keyof typeof LogLevel] as LogLevel
} else {
return defaultLevel;
return defaultLevel
}
}
export interface LogMessage {
scope: string;
level: LogLevel;
message: string | object;
stacktrace: string;
error?: Error;
timestamp: Date;
msg: string | Record<string, unknown>;
stacktrace?: string;
err?: Error;
ts: Date;
}
export default LogMessage;
export type FlattenedLogMessage = Record<string, unknown>;
/**
* Flatten a log message to a plain object in preparation for emission to an
* appender.
*
* In general, 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', ts: 'today', level: LogLevel.WARN });
* ```
*
* Will result in a structured log message after flattening like:
* ```json
* {"scope":"example","level":"INFO","foo":"bar","baz":"qux","ts":"2020-01-01T00:00:00.000Z"}
* ```
*/
export function flattenMessage(logMsg: LogMessage): FlattenedLogMessage {
if (typeof logMsg.msg === 'string') {
return { ...logMsg, level: LogLevel[logMsg.level] }
} else {
const { msg, ...rest } = logMsg
return {
...omit(msg, [
'scope',
'level',
'stacktrace',
'err',
'ts',
]),
...rest,
level: LogLevel[logMsg.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.msg}`
}
export default LogMessage
+52 -18
View File
@@ -1,48 +1,82 @@
import { LogLevel } from './log-message';
import Logger from './logger';
import { LogLevel } from './log-message'
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 {
private loggers: { [key: string]: Logger };
private loggers: Record<string, Logger>
public get ROOT_LOGGER() {
return this.loggers[ROOT_LOGGER_NAME];
return this.loggers[ROOT_LOGGER_NAME]
}
public constructor() {
this.loggers = {};
this.loggers = {}
this.loggers[ROOT_LOGGER_NAME] = new Logger(
ROOT_LOGGER_NAME,
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 {
if (this.loggers[name]) {
return this.loggers[name];
return this.loggers[name]
}
let parentLogger: Logger;
let parentLogger: Logger
const parentLoggerName = Object.keys(this.loggers)
.filter((n: string) => name.startsWith(n))
.reduce(
(acc: string, cur: string) => (acc.length > cur.length ? acc : cur),
''
);
'',
)
if (parentLoggerName) {
parentLogger = this.loggers[parentLoggerName];
parentLogger = this.loggers[parentLoggerName]
} else {
parentLogger = this.ROOT_LOGGER;
parentLogger = this.ROOT_LOGGER
}
this.loggers[name] = parentLogger.createChildLogger(name, threshold);
return this.loggers[name];
this.loggers[name] = parentLogger.createChildLogger(name, threshold)
return this.loggers[name]
}
}
export const logService = new LogService();
export default logService;
export const logService = new LogService()
export default logService
+85 -49
View File
@@ -1,108 +1,144 @@
import { LogMessage, LogLevel } from './log-message';
import LogAppender from './log-appender';
import { LogLevel, LogMessage } from './log-message'
import { LogAppender } from './log-appender'
export type DeferredMsg = () => string | object;
export type MessageType = string | DeferredMsg | object;
export type DeferredMsg = () => string | Record<string, unknown>;
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 {
public appenders: LogAppender[] = [];
public appenders: LogAppender[] = []
public constructor(
public readonly name: string,
private parentLogger?: Logger,
public threshold?: LogLevel
public threshold?: LogLevel,
) {}
public createChildLogger(name: string, threshold?: LogLevel): Logger {
return new Logger(name, this, threshold);
return new Logger(name, this, threshold)
}
public doLog(
level: LogLevel,
message: Error | MessageType,
stacktrace?: string
msg: Error | MessageType,
stacktrace?: string,
): void {
if (level < this.getEffectiveThreshold()) {
return;
return
}
const logMsg: LogMessage = {
scope: this.name,
level,
message: '',
msg: '',
stacktrace: '',
timestamp: new Date()
};
ts: new Date(),
}
if (message === undefined || message === null) {
logMsg.message = message;
logMsg.stacktrace = stacktrace == null ? '' : stacktrace;
} else if ((message as DeferredMsg).call !== undefined) {
logMsg.message = (message as DeferredMsg)();
logMsg.stacktrace = stacktrace == null ? '' : stacktrace;
} else if (message instanceof Error) {
const error = message as Error;
logMsg.error = error;
logMsg.message = `${error.name}: ${error.message}`;
logMsg.stacktrace = error.stack == null ? '' : error.stack;
if (msg === undefined || msg === null) {
logMsg.msg = msg
logMsg.stacktrace = stacktrace ?? ''
} else if (msg instanceof Error) {
const err = msg as Error
logMsg.err = err
logMsg.msg = `${err.name}: ${err.message}`
logMsg.stacktrace = stacktrace ?? err.stack ?? ''
} else if (isDeferredMsg(msg)) {
logMsg.msg = msg()
logMsg.stacktrace = stacktrace == null ? '' : stacktrace
} else {
// string | object
logMsg.message = message;
logMsg.stacktrace = stacktrace == null ? '' : stacktrace;
logMsg.msg = msg
logMsg.stacktrace = stacktrace == null ? '' : stacktrace
}
this.sendToAppenders(logMsg);
this.sendToAppenders(logMsg)
}
public trace(message: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.TRACE, message, stacktrace);
public trace(msg: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.TRACE, msg, stacktrace)
}
public debug(message: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.DEBUG, message, stacktrace);
public debug(msg: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.DEBUG, msg, stacktrace)
}
public log(message: MessageType, stacktrace?: string): void {
this.doLog(LogLevel.LOG, message, stacktrace);
public log(msg: MessageType, stacktrace?: string): void {
this.doLog(LogLevel.LOG, msg, stacktrace)
}
public info(message: MessageType, stacktrace?: string): void {
this.doLog(LogLevel.INFO, message, stacktrace);
public info(msg: MessageType, stacktrace?: string): void {
this.doLog(LogLevel.INFO, msg, stacktrace)
}
public warn(message: MessageType, stacktrace?: string): void {
this.doLog(LogLevel.WARN, message, stacktrace);
public warn(msg: MessageType, stacktrace?: string): void {
this.doLog(LogLevel.WARN, msg, stacktrace)
}
public error(message: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.ERROR, message, stacktrace);
public error(msg: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.ERROR, msg, stacktrace)
}
public fatal(message: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.FATAL, message, stacktrace);
public fatal(msg: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.FATAL, msg, stacktrace)
}
protected sendToAppenders(logMsg: LogMessage) {
this.appenders.forEach(app => {
app.appendMessage(logMsg);
});
this.appenders.forEach((app) => {
app.appendMessage(logMsg)
})
if (this.parentLogger) {
this.parentLogger.sendToAppenders(logMsg);
this.parentLogger.sendToAppenders(logMsg)
}
}
protected getEffectiveThreshold(): LogLevel {
if (this.threshold) {
return this.threshold;
return this.threshold
}
if (this.parentLogger) {
return this.parentLogger.getEffectiveThreshold();
return this.parentLogger.getEffectiveThreshold()
}
// should never happen (root logger should always have a threshold
return LogLevel.ALL;
return LogLevel.ALL
}
}
export default Logger;
export default Logger
+12
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
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, test, expect } from "bun:test";
import { ApiLogAppender, LogLevel, LogMessage, FlattenedLogMessage } from "../src";
function makeMsg(overrides: Partial<LogMessage> = {}): LogMessage {
return {
scope: "test-scope",
level: LogLevel.INFO,
msg: "test message",
stacktrace: "",
ts: new Date(),
...overrides,
};
}
describe("ApiLogAppender", () => {
test("defaults threshold to ALL", () => {
const appender = new ApiLogAppender("https://example.com/logs");
expect(appender.threshold).toBe(LogLevel.ALL);
});
test("respects explicit threshold", () => {
const appender = new ApiLogAppender("https://example.com/logs", undefined, LogLevel.WARN);
expect(appender.threshold).toBe(LogLevel.WARN);
});
test("stores authToken when provided", () => {
const appender = new ApiLogAppender("https://example.com/logs", "token-abc");
expect(appender.authToken).toBe("token-abc");
});
test("has sensible default batch and timing settings", () => {
const appender = new ApiLogAppender("https://example.com/logs");
expect(appender.batchSize).toBe(10);
expect(appender.minimumTimePassedInSec).toBe(60);
expect(appender.maximumTimePassedInSec).toBe(120);
});
test("threshold of ALL (0) does not cause falsy check to drop messages", () => {
const appender = new ApiLogAppender("https://example.com/logs", undefined, LogLevel.ALL);
// appendMessage pushes to the internal msgBuffer — we can't easily
// inspect it, but we can verify it doesn't throw and that the threshold
// check with ALL (0) doesn't drop messages due to falsy comparison.
appender.appendMessage(makeMsg({ level: LogLevel.ALL }));
appender.appendMessage(makeMsg({ level: LogLevel.INFO }));
// No assertions on internal buffer (private), but verify no errors thrown.
// If the falsy bug existed, these messages would be silently dropped.
expect(true).toBe(true);
});
test("respects threshold when set above ALL", () => {
const appender = new ApiLogAppender("https://example.com/logs", undefined, LogLevel.WARN);
// These should not throw, just silently drop
appender.appendMessage(makeMsg({ level: LogLevel.ALL }));
appender.appendMessage(makeMsg({ level: LogLevel.INFO }));
// WARN and above would be queued (but we can't easily inspect)
appender.appendMessage(makeMsg({ level: LogLevel.WARN }));
appender.appendMessage(makeMsg({ level: LogLevel.ERROR }));
expect(true).toBe(true);
});
test("stores apiEndpoint", () => {
const appender = new ApiLogAppender("https://logs.example.com/v1/ingest");
expect(appender.apiEndpoint).toBe("https://logs.example.com/v1/ingest");
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, test, expect } from "bun:test";
import { BufferLogAppender, LogLevel, LogMessage } from "../src";
function makeMsg(overrides: Partial<LogMessage> = {}): LogMessage {
return {
scope: "test-scope",
level: LogLevel.INFO,
msg: "test message",
stacktrace: "",
ts: new Date(),
...overrides,
};
}
describe("BufferLogAppender", () => {
test("defaults to empty buffer and ALL threshold", () => {
const appender = new BufferLogAppender();
expect(appender.buffer).toEqual([]);
expect(appender.threshold).toBe(LogLevel.ALL);
});
test("accepts initial buffer", () => {
const existing: LogMessage[] = [makeMsg({ msg: "pre-existing" })];
const appender = new BufferLogAppender(existing);
expect(appender.buffer.length).toBe(1);
expect(appender.buffer[0].msg).toBe("pre-existing");
});
test("appends messages to buffer", () => {
const appender = new BufferLogAppender();
appender.appendMessage(makeMsg({ msg: "first" }));
appender.appendMessage(makeMsg({ msg: "second" }));
expect(appender.buffer.length).toBe(2);
expect(appender.buffer[0].msg).toBe("first");
expect(appender.buffer[1].msg).toBe("second");
});
test("respects threshold", () => {
const appender = new BufferLogAppender(undefined, LogLevel.WARN);
appender.appendMessage(makeMsg({ level: LogLevel.INFO, msg: "dropped" }));
appender.appendMessage(makeMsg({ level: LogLevel.WARN, msg: "kept" }));
appender.appendMessage(makeMsg({ level: LogLevel.ERROR, msg: "also kept" }));
expect(appender.buffer.length).toBe(2);
expect(appender.buffer[0].msg).toBe("kept");
expect(appender.buffer[1].msg).toBe("also kept");
});
test("threshold of ALL (0) does not cause falsy check to drop messages", () => {
const appender = new BufferLogAppender(undefined, LogLevel.ALL);
appender.appendMessage(makeMsg({ level: LogLevel.ALL, msg: "level all" }));
appender.appendMessage(makeMsg({ level: LogLevel.INFO, msg: "level info" }));
// Both should be kept — ALL (0) should not be treated as falsy
expect(appender.buffer.length).toBe(2);
expect(appender.buffer[0].msg).toBe("level all");
expect(appender.buffer[1].msg).toBe("level info");
});
test("clearBuffer empties the buffer", () => {
const appender = new BufferLogAppender();
appender.appendMessage(makeMsg({ msg: "to clear" }));
expect(appender.buffer.length).toBe(1);
appender.clearBuffer();
expect(appender.buffer.length).toBe(0);
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, test, expect } from "bun:test";
import { ConsoleLogAppender, LogLevel, LogMessage } from "../src";
function makeMsg(overrides: Partial<LogMessage> = {}): LogMessage {
return {
scope: "test-scope",
level: LogLevel.INFO,
msg: "test message",
stacktrace: "",
ts: new Date(),
...overrides,
};
}
describe("ConsoleLogAppender", () => {
test("defaults threshold to ALL and formatter to flattenMessage", () => {
const appender = new ConsoleLogAppender();
expect(appender.threshold).toBe(LogLevel.ALL);
expect(appender.formatter).toBeDefined();
});
test("accepts custom threshold and formatter", () => {
const fmt = () => "custom";
const appender = new ConsoleLogAppender(LogLevel.ERROR, fmt);
expect(appender.threshold).toBe(LogLevel.ERROR);
expect(appender.formatter).toBe(fmt);
});
test("threshold of ALL (0) does not cause falsy guard to block messages", () => {
// ALL (0) is falsy in JS. The guard `if (this.threshold && msg.level < this.threshold)`
// evaluates `0 && ...` = 0 (falsy), so the if-block is NOT entered and
// messages proceed. This is correct behavior by accident — ALL means log everything.
const appender = new ConsoleLogAppender(LogLevel.ALL);
// Calling appendMessage should not throw for any level when threshold is ALL.
// We can't easily spy on console in Bun, but we can verify no errors are thrown.
for (const level of [
LogLevel.ALL,
LogLevel.TRACE,
LogLevel.DEBUG,
LogLevel.INFO,
LogLevel.WARN,
LogLevel.ERROR,
LogLevel.FATAL,
]) {
expect(() => appender.appendMessage(makeMsg({ level }))).not.toThrow();
}
});
test("respects threshold by dropping messages below it", () => {
// We verify threshold works by checking the formatter is called (or not).
// When threshold is WARN, messages with INFO should not invoke the formatter.
let formatterCalls = 0;
const appender = new ConsoleLogAppender(LogLevel.WARN, () => {
formatterCalls++;
return "logs";
});
// This should be dropped before formatter is called
appender.appendMessage(makeMsg({ level: LogLevel.INFO }));
expect(formatterCalls).toBe(0);
// This should pass through
appender.appendMessage(makeMsg({ level: LogLevel.WARN }));
expect(formatterCalls).toBe(1);
// And higher levels too
appender.appendMessage(makeMsg({ level: LogLevel.ERROR }));
expect(formatterCalls).toBe(2);
});
test("does not throw for any valid log level", () => {
const appender = new ConsoleLogAppender();
for (const level of [
LogLevel.ALL,
LogLevel.TRACE,
LogLevel.DEBUG,
LogLevel.LOG,
LogLevel.INFO,
LogLevel.WARN,
LogLevel.ERROR,
LogLevel.FATAL,
]) {
expect(() => appender.appendMessage(makeMsg({ level }))).not.toThrow();
}
});
test("handles string message with error/stacktrace", () => {
const appender = new ConsoleLogAppender();
const err = new Error("test error");
// Should not throw
expect(() =>
appender.appendMessage(makeMsg({ err, stacktrace: "trace" }))
).not.toThrow();
});
test("handles object message formatter output", () => {
// The formatter can return an object (flattenMessage does),
// appendMessage should construct a summary and log it.
const appender = new ConsoleLogAppender(LogLevel.ALL, (msg) => ({
msg: "inner",
extra: 42,
level: LogLevel[msg.level],
scope: msg.scope,
stacktrace: "",
ts: msg.ts,
}));
expect(() => appender.appendMessage(makeMsg())).not.toThrow();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, test, expect } from "bun:test";
import { parseLogLevel, LogLevel, flattenMessage, LogMessage } from "../src";
describe("parseLogLevel", () => {
test("parses known level strings case-sensitively", () => {
expect(parseLogLevel("ALL")).toBe(LogLevel.ALL);
expect(parseLogLevel("TRACE")).toBe(LogLevel.TRACE);
expect(parseLogLevel("DEBUG")).toBe(LogLevel.DEBUG);
expect(parseLogLevel("INFO")).toBe(LogLevel.INFO);
expect(parseLogLevel("WARN")).toBe(LogLevel.WARN);
expect(parseLogLevel("ERROR")).toBe(LogLevel.ERROR);
expect(parseLogLevel("FATAL")).toBe(LogLevel.FATAL);
});
test("returns default level for unknown strings", () => {
expect(parseLogLevel("BOGUS")).toBe(LogLevel.INFO);
expect(parseLogLevel("")).toBe(LogLevel.INFO);
});
test("accepts custom default level", () => {
expect(parseLogLevel("BOGUS", LogLevel.WARN)).toBe(LogLevel.WARN);
});
});
describe("flattenMessage", () => {
test("string message includes all fields", () => {
const msg: LogMessage = {
scope: "my-scope",
level: LogLevel.WARN,
msg: "a warning",
ts: new Date("2024-01-01T00:00:00Z"),
stacktrace: "",
};
const flat = flattenMessage(msg);
expect(flat.scope).toBe("my-scope");
expect(flat.level).toBe("WARN");
expect(flat.msg).toBe("a warning");
});
test("object message promotes fields, drops reserved keys", () => {
const msg: LogMessage = {
scope: "my-scope",
level: LogLevel.INFO,
msg: { foo: "bar", baz: 42, scope: "should-be-dropped" },
ts: new Date("2024-01-01T00:00:00Z"),
stacktrace: "",
};
const flat = flattenMessage(msg);
expect(flat.foo).toBe("bar");
expect(flat.baz).toBe(42);
// Reserved key from the object message should be dropped
expect(flat.scope).toBe("my-scope");
expect(flat.level).toBe("INFO");
});
test("level is serialized as string name", () => {
const msg: LogMessage = {
scope: "test",
level: LogLevel.ERROR,
msg: "error msg",
ts: new Date(),
stacktrace: "",
};
const flat = flattenMessage(msg);
expect(flat.level).toBe("ERROR");
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, test, expect } from "bun:test";
import { LogService, LogLevel } from "../src";
describe("LogService", () => {
test("creates a root logger on construction", () => {
const svc = new LogService();
expect(svc.ROOT_LOGGER).toBeDefined();
expect(svc.ROOT_LOGGER.name).toBe("ROOT");
});
test("root logger defaults to ALL threshold", () => {
const svc = new LogService();
expect(svc.ROOT_LOGGER.threshold).toBe(LogLevel.ALL);
});
test("getLogger returns the same instance for the same name", () => {
const svc = new LogService();
const a = svc.getLogger("foo");
const b = svc.getLogger("foo");
expect(a).toBe(b);
});
test("getLogger creates hierarchical loggers", () => {
const svc = new LogService();
const foo = svc.getLogger("foo");
const fooBar = svc.getLogger("foo.bar");
const fooBarBaz = svc.getLogger("foo.bar.baz");
// foo.bar should be a child of foo
// We can verify by checking threshold propagation
foo.appenders = [{
threshold: LogLevel.ALL,
messages: [] as any[],
appendMessage(m: any) { this.messages!.push(m); },
}];
fooBar.info("propagate");
// The foo appender should receive the message from fooBar
expect(foo.appenders[0].messages.length).toBe(1);
});
test("getLogger allows setting threshold", () => {
const svc = new LogService();
const logger = svc.getLogger("with-threshold", LogLevel.ERROR);
expect(logger.threshold).toBe(LogLevel.ERROR);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, test, expect } from "bun:test";
import { Logger, LogLevel, LogMessage } from "../src";
describe("Logger", () => {
test("uses parent threshold when no threshold is set", () => {
const parent = new Logger("parent", undefined, LogLevel.WARN);
const child = new Logger("child", parent);
// Should not log below WARN (from parent)
const appender = { threshold: LogLevel.ALL, messages: [] as LogMessage[], appendMessage(msg: LogMessage) { this.messages.push(msg); } };
child.appenders = [appender];
child.info("should be dropped");
child.warn("should be logged");
expect(appender.messages.length).toBe(1);
expect(appender.messages[0].msg).toBe("should be logged");
});
test("overrides parent threshold when own threshold is set", () => {
const parent = new Logger("parent", undefined, LogLevel.WARN);
const child = new Logger("child", parent, LogLevel.ERROR);
const appender = { threshold: LogLevel.ALL, messages: [] as LogMessage[], appendMessage(msg: LogMessage) { this.messages.push(msg); } };
child.appenders = [appender];
child.warn("should be dropped");
child.error("should be logged");
expect(appender.messages.length).toBe(1);
expect(appender.messages[0].msg).toBe("should be logged");
});
test("propagates messages to parent appenders", () => {
const parent = new Logger("parent", undefined, LogLevel.ALL);
const parentAppender = { threshold: LogLevel.ALL, messages: [] as LogMessage[], appendMessage(msg: LogMessage) { this.messages.push(msg); } };
parent.appenders = [parentAppender];
const child = new Logger("child", parent);
const childAppender = { threshold: LogLevel.ALL, messages: [] as LogMessage[], appendMessage(msg: LogMessage) { this.messages.push(msg); } };
child.appenders = [childAppender];
child.info("hello");
expect(childAppender.messages.length).toBe(1);
expect(parentAppender.messages.length).toBe(1);
});
test("threshold of ALL (0) is not treated as falsy", () => {
// This is the threshold bug: `this.threshold &&` treats 0 (LogLevel.ALL) as falsy.
// When threshold is explicitly set to LogLevel.ALL (0), messages at ALL level
// should still pass through.
const logger = new Logger("test", undefined, LogLevel.ALL);
const appender = { threshold: LogLevel.ALL, messages: [] as LogMessage[], appendMessage(msg: LogMessage) { this.messages.push(msg); } };
logger.appenders = [appender];
logger.info("should be logged at ALL threshold");
expect(appender.messages.length).toBe(1);
expect(appender.messages[0].msg).toBe("should be logged at ALL threshold");
});
test("handles deferred messages", () => {
const logger = new Logger("test", undefined, LogLevel.ALL);
const appender = { threshold: LogLevel.ALL, messages: [] as LogMessage[], appendMessage(msg: LogMessage) { this.messages.push(msg); } };
logger.appenders = [appender];
logger.info(() => "deferred result");
expect(appender.messages.length).toBe(1);
expect(appender.messages[0].msg).toBe("deferred result");
});
test("handles Error objects", () => {
const logger = new Logger("test", undefined, LogLevel.ALL);
const appender = { threshold: LogLevel.ALL, messages: [] as LogMessage[], appendMessage(msg: LogMessage) { this.messages.push(msg); } };
logger.appenders = [appender];
const err = new Error("boom");
logger.error(err);
expect(appender.messages.length).toBe(1);
expect(appender.messages[0].msg).toBe("Error: boom");
expect(appender.messages[0].err).toBe(err);
});
test("log methods exist for all levels", () => {
const logger = new Logger("test", undefined, LogLevel.ALL);
const appender = { threshold: LogLevel.ALL, messages: [] as LogMessage[], appendMessage(msg: LogMessage) { this.messages.push(msg); } };
logger.appenders = [appender];
logger.trace("trace msg");
logger.debug("debug msg");
logger.log("log msg");
logger.info("info msg");
logger.warn("warn msg");
logger.error("error msg");
logger.fatal("fatal msg");
expect(appender.messages.length).toBe(7);
expect(appender.messages[0].level).toBe(LogLevel.TRACE);
expect(appender.messages[6].level).toBe(LogLevel.FATAL);
});
});
+3 -2
View File
@@ -1,10 +1,11 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"target": "es2016",
"declaration": true,
"outDir": "./dist",
"strict": true
"strict": true,
"skipLibCheck": true
},
"include": [
"src/**/*"