13 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
9 changed files with 394 additions and 59 deletions
+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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jdbernard/logging",
"version": "2.3.3",
"version": "2.5.0",
"description": "Simple Javascript logging service.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+61 -4
View File
@@ -1,21 +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[]
constructor(buffer?: LogMessage[], threshold?: LogLevel) {
this.buffer = buffer ?? []
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
else this.buffer.push(msg)
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.length = 0
this.buffer = []
}
}
+11
View File
@@ -10,3 +10,14 @@ export function omit(
}
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
}
+205 -52
View File
@@ -1,74 +1,227 @@
import { describe, test, expect } from "bun:test";
import { BufferLogAppender, LogLevel, LogMessage } from "../src";
import { describe, expect, test } from 'bun:test'
import {
BufferLogAppender,
LogLevel,
type LogMessage,
} from '../src'
function makeMsg(overrides: Partial<LogMessage> = {}): LogMessage {
return {
scope: "test-scope",
scope: 'test-scope',
level: LogLevel.INFO,
msg: "test message",
stacktrace: "",
msg: 'test message',
stacktrace: '',
ts: new Date(),
...overrides,
};
}
}
describe("BufferLogAppender", () => {
test("defaults to empty buffer and ALL threshold", () => {
const appender = new BufferLogAppender();
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.buffer).toEqual([])
expect(appender.threshold).toBe(LogLevel.ALL)
expect(appender.size).toEqual({ target: 1000, max: 1200 })
})
test("accepts initial buffer", () => {
const existing: LogMessage[] = [makeMsg({ msg: "pre-existing" })];
const appender = new BufferLogAppender(existing);
test('accepts threshold and size constructor options', () => {
const appender = new BufferLogAppender(LogLevel.WARN, {
target: 20,
max: 30,
})
expect(appender.buffer.length).toBe(1);
expect(appender.buffer[0].msg).toBe("pre-existing");
});
expect(appender.threshold).toBe(LogLevel.WARN)
expect(appender.size).toEqual({ target: 20, max: 30 })
})
test("appends messages to buffer", () => {
const appender = new BufferLogAppender();
test('appends messages to buffer', () => {
const appender = new BufferLogAppender()
appender.appendMessage(makeMsg({ msg: "first" }));
appender.appendMessage(makeMsg({ msg: "second" }));
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");
});
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);
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" }));
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");
});
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);
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" }));
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");
});
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);
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.length).toBe(0);
});
});
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 },
})
})
})
})
+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