20 Commits

Author SHA1 Message Date
jdb 9e8ee446d1 Version 2.5.0 -- breaking change to BufferLogAppender's public constract.
Validate / test (push) Successful in 20s
BufferLogAppender now accepts threshold and optional size bounds via its
constructor, replaces bufferMax with the size target/max API, defaults
to bounded buffering, and may reassign its buffer during trim/clear
operations.

Also adds batched buffer trimming for high-throughput logging, a clamp
utility for bounds normalization, and expanded tests for buffer sizing,
trimming behavior, and clamp semantics.
2026-05-07 07:37:00 -05:00
jdb bf96303bf9 Update tests for BufferLogAppender and its utilities.
AI-Assisted: yes
AI-Tool: OpenAI Codex / gpt-5.5 xhigh
2026-05-07 07:37:00 -05:00
jdb 1546973475 Rework BufferLogAppender buffer sizing logic to be more performant in the face of heavy throughput. 2026-05-07 07:37:00 -05:00
jdb 5e167b7fb4 Include test in ESLint coverage. 2026-05-07 07:37:00 -05:00
mahseiah_ai ebcd7880fb feat: add buffer max to buffer appender 2026-05-07 07:37:00 -05:00
Mahseiah 70233cc941 docs: Add Development, CI, and Maintenance sections to README (fixes #7) 2026-05-07 07:37:00 -05:00
mahseiah_ai 66568b5e0f ci: switch to bun runner, remove setup-bun steps 2026-05-07 07:37:00 -05:00
mahseiah_ai ff0f797d20 ci: replace oven-sh/setup-bun with curl install for Gitea Actions compatibility
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-07 07:37:00 -05:00
mahseiah_ai 9ebac95c27 test: add comprehensive unit tests for all appenders and logger
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-07 07:37:00 -05:00
mahseiah_ai 7bb80989c4 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-07 07:37:00 -05:00
mahseiah_ai a6aff1a5b9 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-07 07:37:00 -05:00
mahseiah_ai c957150879 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-07 07:37:00 -05:00
jdb f405c0bc5a bump version for v2.4.0. 2026-05-05 08:54:00 -05: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
23 changed files with 1009 additions and 142 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
+54
View File
@@ -161,6 +161,60 @@ 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.
## Development
Set up the project locally:
```bash
bun install
```
Build the TypeScript sources:
```bash
bunx tsc
```
Run the test suite:
```bash
bun test
```
Run tests with coverage:
```bash
bun test --coverage
```
Before opening a pull request, make sure the project builds and all tests pass:
```bash
bunx tsc && bun test
```
## CI
The repository uses Gitea Actions for continuous integration. The `Validate` workflow runs on every push and pull request to `main` and performs the following steps:
1. Checks out the repository.
2. Installs dependencies with `bun install`.
3. Runs the test suite with `bun test`.
## Release / Maintenance
Releases are created from the `main` branch. The high-level maintainer workflow is:
1. Update `version` in `package.json` following semantic versioning.
2. Ensure the build succeeds and all tests pass locally (`bunx tsc && bun test`).
3. Commit the version bump.
4. Create an annotated Git tag in the `x.y.z` format (for example, `2.4.1`).
5. Push the commit and the tag to the remote repository.
6. Verify that CI passes on the tag.
7. Publish the package to npm (e.g. `npm publish`).
Tags must use the `x.y.z` format (three numeric components, no leading `v`). This convention is used consistently for releases in this project.
[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
View File
Binary file not shown.
+1 -1
View File
@@ -3,7 +3,7 @@ import tsParser from '@typescript-eslint/parser'
import eslintJs from '@eslint/js'
import eslintTs from 'typescript-eslint'
const tsFiles = ['src/**/*.ts']
const tsFiles = ['src/**/*.ts', 'test/**/*.ts']
const customTypescriptConfig = {
files: tsFiles,
+3
View File
@@ -0,0 +1,3 @@
[tools]
node = "latest"
deno = "none"
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@jdbernard/logging",
"version": "2.3.0",
"version": "2.5.0",
"description": "Simple Javascript logging service.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -10,7 +10,8 @@
"scripts": {
"build": "bunx tsc",
"prepare": "npm run build",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "bun test",
"test:coverage": "bun test --coverage"
},
"repository": {
"type": "git",
+22 -24
View File
@@ -1,32 +1,30 @@
import { LogMessage, LogLevel, flattenMessage, FlattenedLogMessage } from './log-message';
import { LogAppender } from './log-appender';
import { LogMessage, LogLevel, flattenMessage, FlattenedLogMessage } from './log-message'
import { LogAppender } from './log-appender'
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 msgBuffer: FlattenedLogMessage[] = [];
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(flattenMessage(msg));
this.msgBuffer.push(flattenMessage(msg))
}
private doPost() {
@@ -39,29 +37,29 @@ export class ApiLogAppender implements LogAppender {
'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
+78
View File
@@ -0,0 +1,78 @@
import type { LogAppender } from './log-appender'
import { LogLevel, type LogMessage } from './log-message'
import { clamp } from './util'
export interface BufferSizeBounds {
target: number
max: number
}
export class BufferLogAppender implements LogAppender {
public threshold: LogLevel
public buffer: LogMessage[]
private _size?: BufferSizeBounds
public get size() {
return this._size === undefined ? undefined : { ...this._size }
}
public set size(s: {target: number, max?: number} | undefined) {
if (s === undefined) {
this._size = undefined
} else {
const target = clamp(s.target, { min: 1 })
this._size = {
target,
max: s.max === undefined
? clamp(target * 1.2, { min: target + 1 })
: clamp(s.max, { min: target + 1 })
}
}
}
constructor(threshold?: LogLevel, size?: {target: number, max?: number}) {
if (size !== undefined && typeof size === 'object') {
this.size = size
} else {
this._size = { target: 1000, max: 1200 }
}
this.buffer = []
this.threshold = threshold ?? LogLevel.ALL
}
public appendMessage(msg: LogMessage): void {
if (this.threshold && msg.level < this.threshold) return
this.buffer.push(msg)
if (this._size !== undefined) {
if (this._size.max <= this._size.target) {
// This should be impossible, so if it happens, I want to know.
const oldSize = this.size
this.size = { target: this._size.target }
this.buffer.push({
scope: '@jdbernard/js-logging/buffer-log-appender.ts',
level: LogLevel.ERROR,
msg: {
msg: 'BufferLogAppender misconfigured: max size was not greater ' +
'than target size. Reconfiguring.',
oldSize,
newSize: this.size
},
ts: new Date()
})
}
if (this.buffer.length > this._size.max) {
this.buffer = this.buffer.slice(-this._size.target)
}
}
}
public clearBuffer(): void {
this.buffer = []
}
}
+32 -34
View File
@@ -3,8 +3,8 @@ import {
LogLevel,
LogMessage,
LogMessageFormatter,
} from "./log-message";
import { LogAppender } from "./log-appender";
} from './log-message'
import { LogAppender } from './log-appender'
/**
* A log appender that writes log messages to the console. The behavior of the
@@ -18,68 +18,66 @@ import { LogAppender } from "./log-appender";
* data for inspection in the browser's developer tools.
*/
export class ConsoleLogAppender implements LogAppender {
public threshold = LogLevel.ALL;
public formatter: LogMessageFormatter = flattenMessage;
public threshold: LogLevel
public formatter: LogMessageFormatter
constructor(threshold?: LogLevel, formatter?: LogMessageFormatter) {
if (threshold) {
this.threshold = threshold;
}
if (formatter) {
this.formatter = formatter;
}
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:
case LogLevel.TRACE:
logMethod = console.trace;
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;
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.error;
break;
logMethod = console.error
break
}
const fmtMsg = this.formatter(msg);
const fmtMsg = this.formatter(msg)
if (typeof fmtMsg === "string") {
if (typeof fmtMsg === 'string') {
if (msg.err || msg.stacktrace) {
logMethod(fmtMsg, msg.err ?? msg.stacktrace);
logMethod(fmtMsg, msg.err ?? msg.stacktrace)
} else {
logMethod(fmtMsg);
logMethod(fmtMsg)
}
} else {
const { message, _err, _stacktrace, ...rest } = fmtMsg;
const { msg: innerMsg, _err, _stacktrace, ...rest } = fmtMsg
const summary = `${LogLevel[msg.level]} -- ${msg.scope}: ${
message ?? fmtMsg.method
}\n`;
innerMsg ?? fmtMsg.method
}\n`
if (msg.err || msg.stacktrace) {
logMethod(summary, msg.err ?? msg.stacktrace, rest);
logMethod(summary, msg.err ?? msg.stacktrace, rest)
} else {
logMethod(summary, rest);
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'
+2 -2
View File
@@ -1,8 +1,8 @@
import { LogLevel, LogMessage } from './log-message';
import { LogLevel, LogMessage } from './log-message'
export interface LogAppender {
threshold: LogLevel;
appendMessage(message: LogMessage): void;
}
export default LogAppender;
export default LogAppender
+15 -15
View File
@@ -1,4 +1,4 @@
import { omit } from "./util";
import { omit } from './util'
export enum LogLevel {
ALL = 0,
@@ -16,9 +16,9 @@ export function parseLogLevel(
defaultLevel = LogLevel.INFO,
): LogLevel {
if (str in LogLevel) {
return LogLevel[str as keyof typeof LogLevel] as LogLevel;
return LogLevel[str as keyof typeof LogLevel] as LogLevel
} else {
return defaultLevel;
return defaultLevel
}
}
@@ -57,21 +57,21 @@ export type FlattenedLogMessage = Record<string, unknown>;
* ```
*/
export function flattenMessage(logMsg: LogMessage): FlattenedLogMessage {
if (typeof logMsg.msg === "string") {
return { ...logMsg, level: LogLevel[logMsg.level] };
if (typeof logMsg.msg === 'string') {
return { ...logMsg, level: LogLevel[logMsg.level] }
} else {
const { msg, ...rest } = logMsg;
const { msg, ...rest } = logMsg
return {
...omit(msg, [
"scope",
"level",
"stacktrace",
"err",
"ts",
'scope',
'level',
'stacktrace',
'err',
'ts',
]),
...rest,
level: LogLevel[logMsg.level],
};
}
}
}
export type LogMessageFormatter = (
@@ -79,11 +79,11 @@ export type LogMessageFormatter = (
) => string | FlattenedLogMessage;
export function structuredLogMessageFormatter(msg: LogMessage): string {
return JSON.stringify(flattenMessage(msg));
return JSON.stringify(flattenMessage(msg))
}
export function simpleTextLogMessageFormatter(msg: LogMessage): string {
return `[${msg.scope}] - ${msg.level}: ${msg.msg}`;
return `[${msg.scope}] - ${msg.level}: ${msg.msg}`
}
export default LogMessage;
export default LogMessage
+16 -16
View File
@@ -1,7 +1,7 @@
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
@@ -10,19 +10,19 @@ const ROOT_LOGGER_NAME = 'ROOT';
* module.
*/
export class LogService {
private loggers: Record<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,
);
)
}
/**
@@ -55,28 +55,28 @@ export class LogService {
*/
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
+38 -38
View File
@@ -1,11 +1,11 @@
import { LogLevel, LogMessage } from './log-message';
import { LogAppender } from './log-appender';
import { LogLevel, LogMessage } from './log-message'
import { LogAppender } from './log-appender'
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';
return typeof msg === 'function'
}
/**
@@ -41,7 +41,7 @@ export function isDeferredMsg(msg: MessageType): msg is DeferredMsg {
* For more details, see *LogService#getLogger*.
*/
export class Logger {
public appenders: LogAppender[] = [];
public appenders: LogAppender[] = []
public constructor(
public readonly name: string,
@@ -50,7 +50,7 @@ export class Logger {
) {}
public createChildLogger(name: string, threshold?: LogLevel): Logger {
return new Logger(name, this, threshold);
return new Logger(name, this, threshold)
}
public doLog(
@@ -59,7 +59,7 @@ export class Logger {
stacktrace?: string,
): void {
if (level < this.getEffectiveThreshold()) {
return;
return
}
const logMsg: LogMessage = {
@@ -68,77 +68,77 @@ export class Logger {
msg: '',
stacktrace: '',
ts: new Date(),
};
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.msg = msg;
logMsg.stacktrace = stacktrace == null ? '' : stacktrace;
}
this.sendToAppenders(logMsg);
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.msg = msg
logMsg.stacktrace = stacktrace == null ? '' : stacktrace
}
this.sendToAppenders(logMsg)
}
public trace(msg: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.TRACE, msg, stacktrace);
this.doLog(LogLevel.TRACE, msg, stacktrace)
}
public debug(msg: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.DEBUG, msg, stacktrace);
this.doLog(LogLevel.DEBUG, msg, stacktrace)
}
public log(msg: MessageType, stacktrace?: string): void {
this.doLog(LogLevel.LOG, msg, stacktrace);
this.doLog(LogLevel.LOG, msg, stacktrace)
}
public info(msg: MessageType, stacktrace?: string): void {
this.doLog(LogLevel.INFO, msg, stacktrace);
this.doLog(LogLevel.INFO, msg, stacktrace)
}
public warn(msg: MessageType, stacktrace?: string): void {
this.doLog(LogLevel.WARN, msg, stacktrace);
this.doLog(LogLevel.WARN, msg, stacktrace)
}
public error(msg: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.ERROR, msg, stacktrace);
this.doLog(LogLevel.ERROR, msg, stacktrace)
}
public fatal(msg: Error | MessageType, stacktrace?: string): void {
this.doLog(LogLevel.FATAL, msg, stacktrace);
this.doLog(LogLevel.FATAL, msg, stacktrace)
}
protected sendToAppenders(logMsg: LogMessage) {
this.appenders.forEach((app) => {
app.appendMessage(logMsg);
});
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
+14 -3
View File
@@ -2,11 +2,22 @@ export function omit(
obj: Record<string, unknown>,
keys: string[],
): Record<string, unknown> {
const result: Record<string, unknown> = {};
const result: Record<string, unknown> = {}
for (const key in obj) {
if (!keys.includes(key)) {
result[key] = obj[key];
result[key] = obj[key]
}
}
return result;
return result
}
export function clamp(
val: number,
bounds?: { min?: number, max?: number},
allowFloats?: boolean ): number {
let clamped = val
if (!allowFloats) clamped = Math.floor(clamped)
if (bounds?.min !== undefined) clamped = Math.max(bounds?.min, clamped)
if (bounds?.max !== undefined) clamped = Math.min(bounds?.max, clamped)
return clamped
}
+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");
});
});
+227
View File
@@ -0,0 +1,227 @@
import { describe, expect, test } from 'bun:test'
import {
BufferLogAppender,
LogLevel,
type 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, ALL threshold, and bounded size', () => {
const appender = new BufferLogAppender()
expect(appender.buffer).toEqual([])
expect(appender.threshold).toBe(LogLevel.ALL)
expect(appender.size).toEqual({ target: 1000, max: 1200 })
})
test('accepts threshold and size constructor options', () => {
const appender = new BufferLogAppender(LogLevel.WARN, {
target: 20,
max: 30,
})
expect(appender.threshold).toBe(LogLevel.WARN)
expect(appender.size).toEqual({ target: 20, max: 30 })
})
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(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 does not drop messages', () => {
const appender = new BufferLogAppender(LogLevel.ALL)
appender.appendMessage(makeMsg({ level: LogLevel.ALL, msg: 'level all' }))
appender.appendMessage(makeMsg({ level: LogLevel.INFO, msg: 'level info' }))
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 and reassigns the array', () => {
const appender = new BufferLogAppender()
appender.appendMessage(makeMsg({ msg: 'to clear' }))
const existing = appender.buffer
appender.clearBuffer()
expect(appender.buffer).toEqual([])
expect(appender.buffer).not.toBe(existing)
})
describe('size bounds', () => {
test('derives max as 120 percent of target by default', () => {
const appender = new BufferLogAppender(LogLevel.ALL, { target: 20 })
expect(appender.size).toEqual({ target: 20, max: 24 })
})
test('keeps max at least one greater than target', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 20,
max: 10,
})
expect(appender.size).toEqual({ target: 20, max: 21 })
})
test('normalizes fractional and too-small target values', () => {
const appender = new BufferLogAppender(LogLevel.ALL, { target: 0.5 })
expect(appender.size).toEqual({ target: 1, max: 2 })
})
test('normalizes fractional max values', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 3,
max: 4.9,
})
expect(appender.size).toEqual({ target: 3, max: 4 })
})
test('can be disabled for unbounded buffering', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 2,
max: 3,
})
appender.size = undefined
for (let i = 1; i <= 5; i++) {
appender.appendMessage(makeMsg({ msg: `message ${i}` }))
}
expect(appender.size).toBeUndefined()
expect(appender.buffer.map((message) => message.msg)).toEqual([
'message 1',
'message 2',
'message 3',
'message 4',
'message 5',
])
})
test('returns size as a defensive copy', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 3,
max: 4,
})
const size = appender.size
if (size !== undefined) {
size.max = 1
}
expect(appender.size).toEqual({ target: 3, max: 4 })
})
})
describe('buffer trimming', () => {
test('allows buffer to grow until max is exceeded', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 3,
max: 5,
})
for (let i = 1; i <= 5; i++) {
appender.appendMessage(makeMsg({ msg: `message ${i}` }))
}
expect(appender.buffer.map((message) => message.msg)).toEqual([
'message 1',
'message 2',
'message 3',
'message 4',
'message 5',
])
})
test('batch trims to target when max is exceeded', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 3,
max: 5,
})
for (let i = 1; i <= 6; i++) {
appender.appendMessage(makeMsg({ msg: `message ${i}` }))
}
expect(appender.buffer.map((message) => message.msg)).toEqual([
'message 4',
'message 5',
'message 6',
])
})
test('reassigns buffer array when trimming', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 2,
max: 3,
})
appender.appendMessage(makeMsg({ msg: 'first' }))
appender.appendMessage(makeMsg({ msg: 'second' }))
appender.appendMessage(makeMsg({ msg: 'third' }))
const existing = appender.buffer
appender.appendMessage(makeMsg({ msg: 'fourth' }))
expect(appender.buffer).not.toBe(existing)
expect(appender.buffer.map((message) => message.msg)).toEqual([
'third',
'fourth',
])
})
test('repairs invalid internal bounds before trimming', () => {
const appender = new BufferLogAppender(LogLevel.ALL, {
target: 3,
max: 4,
})
const unsafeAppender = appender as unknown as {
_size: { target: number, max: number }
}
unsafeAppender._size = { target: 3, max: 2 }
appender.appendMessage(makeMsg({ msg: 'first' }))
expect(appender.size).toEqual({ target: 3, max: 4 })
expect(appender.buffer.length).toBe(2)
expect(appender.buffer[1].level).toBe(LogLevel.ERROR)
expect(appender.buffer[1].msg).toMatchObject({
oldSize: { target: 3, max: 2 },
newSize: { target: 3, max: 4 },
})
})
})
})
+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);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, test } from 'bun:test'
import { clamp } from '../src/util'
describe('utils', () => {
describe('clamp', () => {
test("doesn't alter integers within the given range", () => {
expect(clamp(5, { min: 0, max: 10 })).toBe(5)
expect(clamp(-8, { min: -100, max: 100 })).toBe(-8)
})
test('keeps values at the range boundaries', () => {
expect(clamp(0, { min: 0, max: 10 })).toBe(0)
expect(clamp(10, { min: 0, max: 10 })).toBe(10)
expect(clamp(-100, { min: -100, max: 100 })).toBe(-100)
expect(clamp(100, { min: -100, max: 100 })).toBe(100)
})
test('raises values below the minimum', () => {
expect(clamp(-1, { min: 0, max: 10 })).toBe(0)
expect(clamp(-101, { min: -100, max: 100 })).toBe(-100)
})
test('lowers values above the maximum', () => {
expect(clamp(11, { min: 0, max: 10 })).toBe(10)
expect(clamp(101, { min: -100, max: 100 })).toBe(100)
})
test('floors floats by default', () => {
expect(clamp(1.5, { min: 0, max: 2 })).toBe(1)
expect(clamp(1.999, { min: 0, max: 2 })).toBe(1)
expect(clamp(-0.3, { min: -2, max: 2 })).toBe(-1)
expect(clamp(-1.1, { min: -2, max: 2 })).toBe(-2)
})
test('floors before applying bounds', () => {
expect(clamp(10.5, { min: 0, max: 10 })).toBe(10)
expect(clamp(-10.5, { min: -10, max: 10 })).toBe(-10)
expect(clamp(1.2, { min: 1.5 })).toBe(1.5)
})
test('preserves floats when allowed', () => {
expect(clamp(1.5, { min: 0, max: 2 }, true)).toBe(1.5)
expect(clamp(2.5, { min: 0, max: 2 }, true)).toBe(2)
expect(clamp(-0.5, { min: 0, max: 2 }, true)).toBe(0)
})
test('supports one-sided bounds', () => {
expect(clamp(-1, { min: 0 })).toBe(0)
expect(clamp(11, { min: 0 })).toBe(11)
expect(clamp(-1, { max: 10 })).toBe(-1)
expect(clamp(11, { max: 10 })).toBe(10)
})
test('clamps infinite values to the range boundaries', () => {
expect(clamp(Infinity, { min: 0, max: 10 })).toBe(10)
expect(clamp(-Infinity, { min: 0, max: 10 })).toBe(0)
})
})
})
+1
View File
@@ -3,6 +3,7 @@
"module": "commonjs",
"target": "es2016",
"declaration": true,
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"skipLibCheck": true