1 Commits

Author SHA1 Message Date
jdb f405c0bc5a bump version for v2.4.0. 2026-05-05 08:54:00 -05:00
11 changed files with 3 additions and 569 deletions
-20
View File
@@ -1,20 +0,0 @@
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,60 +161,6 @@ 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 the API log collector, but ignore the rest of the application regardless of the
logging level. 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/ [log4j]: https://logging.apache.org/log4j/2.x/
[logback]: https://logback.qos.ch/ [logback]: https://logback.qos.ch/
[effective logging level]: https://logback.qos.ch/manual/architecture.html#effectiveLevel [effective logging level]: https://logback.qos.ch/manual/architecture.html#effectiveLevel
BIN
View File
Binary file not shown.
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jdbernard/logging", "name": "@jdbernard/logging",
"version": "2.3.3", "version": "2.4.0",
"description": "Simple Javascript logging service.", "description": "Simple Javascript logging service.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@@ -10,8 +10,7 @@
"scripts": { "scripts": {
"build": "bunx tsc", "build": "bunx tsc",
"prepare": "npm run build", "prepare": "npm run build",
"test": "bun test", "test": "echo \"Error: no test specified\" && exit 1"
"test:coverage": "bun test --coverage"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
-1
View File
@@ -3,5 +3,4 @@ export * from './log-appender'
export * from './log-service' export * from './log-service'
export * from './console-log-appender' export * from './console-log-appender'
export * from './api-log-appender' export * from './api-log-appender'
export * from './buffer-log-appender'
export * from './logger' export * from './logger'
-71
View File
@@ -1,71 +0,0 @@
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
@@ -1,74 +0,0 @@
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
@@ -1,115 +0,0 @@
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
@@ -1,72 +0,0 @@
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
@@ -1,54 +0,0 @@
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
@@ -1,104 +0,0 @@
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);
});
});