@prosopo/logger 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/logger.ts ADDED
@@ -0,0 +1,325 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { stringifyBigInts } from "@prosopo/util";
16
+ import { z } from "zod";
17
+
18
+ export { stringifyBigInts };
19
+
20
+ export type LogObject = object;
21
+ export type LogRecord = {
22
+ err?: unknown;
23
+ data?: LogObject;
24
+ msg?: string;
25
+ };
26
+ export type LogRecordFn = () => LogRecord;
27
+
28
+ export type Logger = {
29
+ setLogLevel(level: LogLevel): void;
30
+ getLogLevel(): LogLevel;
31
+ getScope(): string;
32
+ info(fn: LogRecordFn): void;
33
+ debug(fn: LogRecordFn): void;
34
+ trace(fn: LogRecordFn): void;
35
+ warn(fn: LogRecordFn): void;
36
+ error(fn: LogRecordFn): void;
37
+ fatal(fn: LogRecordFn): void;
38
+ log(level: LogLevel, fn: LogRecordFn): void;
39
+ /**
40
+ * Creates a new logger instance which includes the given object in every log message. Akin to a child logger.
41
+ * This is useful for adding context to log messages, such as user IDs, request IDs, etc.
42
+ * @param obj An object to log, which will be added to every log message.
43
+ */
44
+ with(obj: LogObject): Logger;
45
+ getPretty(): boolean;
46
+ setPretty(pretty: boolean): void;
47
+ getPrintStack(): boolean;
48
+ setPrintStack(printStack: boolean): void;
49
+ getFormat(): Format;
50
+ setFormat(format: Format): void;
51
+ };
52
+
53
+ export const InfoLevel = "info";
54
+ export const DebugLevel = "debug";
55
+ export const TraceLevel = "trace";
56
+ export const WarnLevel = "warn";
57
+ export const ErrorLevel = "error";
58
+ export const FatalLevel = "fatal";
59
+
60
+ export const LogLevel = z.enum([
61
+ InfoLevel,
62
+ DebugLevel,
63
+ TraceLevel,
64
+ WarnLevel,
65
+ ErrorLevel,
66
+ FatalLevel,
67
+ ]);
68
+ export type LogLevel = z.infer<typeof LogLevel>;
69
+
70
+ export type LevelMap = {
71
+ [K in LogLevel]: number;
72
+ };
73
+
74
+ const logLevelMap: LevelMap = {
75
+ [TraceLevel]: 0,
76
+ [DebugLevel]: 1,
77
+ [InfoLevel]: 2,
78
+ [WarnLevel]: 3,
79
+ [ErrorLevel]: 4,
80
+ [FatalLevel]: 5,
81
+ };
82
+
83
+ export function parseLogLevel(
84
+ level: string | undefined,
85
+ or: LogLevel = InfoLevel,
86
+ ): LogLevel {
87
+ const result = LogLevel.safeParse(level);
88
+ return result.success ? result.data : or;
89
+ }
90
+
91
+ // Create a new logger with the given level and scope
92
+ export function getLogger(logLevel: LogLevel, scope: string): Logger {
93
+ const logger = new NativeLogger(scope);
94
+ logger.setLogLevel(logLevel);
95
+ return logger;
96
+ }
97
+
98
+ const inBrowser =
99
+ typeof window !== "undefined" && typeof window.document !== "undefined";
100
+
101
+ export const FormatJson = "json";
102
+ export const FormatPlain = "plain";
103
+ export const Format = z.enum([FormatJson, FormatPlain]);
104
+ export type Format = z.infer<typeof Format>;
105
+
106
+ /**
107
+ * Native logger which uses console.log, console.error, etc, without any libraries.
108
+ */
109
+ export class NativeLogger implements Logger {
110
+ // the default data to be logged
111
+ // this provides utility for adding properties to every log message
112
+ private defaultData: LogObject | undefined;
113
+ private level: LogLevel;
114
+ private levelNum: number;
115
+ private pretty = 0; // pretty print indentation level - 0 = no pretty print, 2 = 2 spaces, etc
116
+ private printStack = false; // whether to print the stack trace in the log
117
+ private format: Format = FormatJson;
118
+
119
+ constructor(
120
+ private scope: string,
121
+ private levelMap: LevelMap = logLevelMap,
122
+ ) {
123
+ this.level = InfoLevel; // default log level
124
+ this.levelNum = this.levelMap[this.level];
125
+ }
126
+
127
+ setFormat(format: Format): void {
128
+ this.format = format;
129
+ if (this.format !== FormatJson) {
130
+ throw new Error("Only JSON format implemented for now"); // for performance reasons
131
+ }
132
+ }
133
+
134
+ getFormat(): Format {
135
+ return this.format;
136
+ }
137
+
138
+ with(obj: LogObject): Logger {
139
+ const newLogger = new NativeLogger(this.scope);
140
+ newLogger.defaultData = { ...this.defaultData, ...obj };
141
+ newLogger.setLogLevel(this.getLogLevel());
142
+ return newLogger;
143
+ }
144
+
145
+ getPrintStack(): boolean {
146
+ return this.printStack;
147
+ }
148
+
149
+ setPrintStack(printStack: boolean): void {
150
+ this.printStack = printStack;
151
+ }
152
+
153
+ getPretty(): boolean {
154
+ return this.pretty > 0;
155
+ }
156
+
157
+ setPretty(pretty: boolean): void {
158
+ this.pretty = pretty ? 2 : 0;
159
+ }
160
+
161
+ getScope(): string {
162
+ return this.scope;
163
+ }
164
+
165
+ setLogLevel(level: LogLevel): void {
166
+ this.level = level;
167
+ this.levelNum = this.levelMap[level];
168
+ }
169
+
170
+ getLogLevel(): LogLevel {
171
+ return this.level;
172
+ }
173
+
174
+ private unpackError(err: Error | object): {
175
+ msg: string;
176
+ data: Record<string, unknown>;
177
+ } {
178
+ const e = err as Record<string, unknown>;
179
+ const data: Record<string, unknown> = {};
180
+
181
+ for (const key of Object.getOwnPropertyNames(err)) {
182
+ if (!this.printStack && (key === "stack" || key === "stacktrace"))
183
+ continue;
184
+ data[key] = e[key];
185
+ }
186
+
187
+ if (!data.name) {
188
+ data.name = (err as { name?: unknown }).name ?? "Error";
189
+ }
190
+
191
+ // Recursively unpack the cause chain
192
+ const cause = e.cause;
193
+ if (cause !== undefined) {
194
+ if (
195
+ cause instanceof Error ||
196
+ (typeof cause === "object" && cause !== null)
197
+ ) {
198
+ data.cause = this.unpackError(cause as Error | object);
199
+ } else {
200
+ data.cause = cause;
201
+ }
202
+ }
203
+
204
+ // Prefer translationKey when present (e.g. ProsopoBaseError) so the
205
+ // top-level `err` field is locale-stable and queryable.
206
+ const msg = String(e.translationKey ?? e.message ?? e.msg ?? "");
207
+ return { msg, data };
208
+ }
209
+
210
+ private print(
211
+ dest: (...args: unknown[]) => void,
212
+ fn: LogRecordFn,
213
+ level: LogLevel,
214
+ ): void {
215
+ if (this.levelMap[level] < this.levelNum) {
216
+ return; // skip logging if the level is below the current log level threshold
217
+ }
218
+ const ts = new Date().toISOString();
219
+ // populate the log fields using the fn
220
+ let { data, msg, err } = fn();
221
+ let errMsg: string | undefined;
222
+ let errData: Record<string, unknown> | undefined;
223
+ if (err) {
224
+ if (err instanceof Error || typeof err === "object") {
225
+ // if it's an instance of Error, unpack the standard fields (e.g. message, name, stack, etc)
226
+ const result = this.unpackError(err);
227
+ errMsg = result.msg;
228
+ errData = result.data;
229
+ } else {
230
+ // primitive
231
+ errMsg = String(err);
232
+ }
233
+ }
234
+ // add any default data to the data object
235
+ if (this.defaultData) {
236
+ data = { ...this.defaultData, ...data };
237
+ }
238
+ const baseRecord: {
239
+ scope: string;
240
+ ts: string;
241
+ level: LogLevel;
242
+ data?: LogObject;
243
+ msg?: string;
244
+ err?: string;
245
+ errData?: Record<string, unknown>;
246
+ } = { scope: this.scope, ts, level };
247
+ if (data) {
248
+ baseRecord.data = data;
249
+ }
250
+ if (msg) {
251
+ baseRecord.msg = msg;
252
+ }
253
+ if (errMsg) {
254
+ baseRecord.err = errMsg;
255
+ }
256
+ if (errData) {
257
+ baseRecord.errData = errData;
258
+ }
259
+
260
+ if (inBrowser) {
261
+ // no need to convert to json, dev tools will handle it
262
+ if (msg || errMsg) {
263
+ // log the log record object separately to the message to utilize browser dev tools
264
+ dest(msg || errMsg, baseRecord);
265
+ } else {
266
+ dest(baseRecord);
267
+ }
268
+ } else {
269
+ // conversion to avoid "TypeError: Do not know how to serialize a BigInt" in JSON.stringify
270
+ const logRecord = stringifyBigInts(baseRecord) as object;
271
+
272
+ const output = JSON.stringify(logRecord, null, this.pretty);
273
+ dest(output);
274
+ }
275
+ }
276
+
277
+ info(fn: LogRecordFn): void {
278
+ this.print(console.info.bind(console), fn, InfoLevel);
279
+ }
280
+
281
+ debug(fn: LogRecordFn): void {
282
+ this.print(console.debug.bind(console), fn, DebugLevel);
283
+ }
284
+
285
+ trace(fn: LogRecordFn): void {
286
+ this.print(console.trace.bind(console), fn, TraceLevel);
287
+ }
288
+
289
+ warn(fn: LogRecordFn): void {
290
+ this.print(console.warn.bind(console), fn, WarnLevel);
291
+ }
292
+
293
+ error(fn: LogRecordFn): void {
294
+ this.print(console.error.bind(console), fn, ErrorLevel);
295
+ }
296
+
297
+ fatal(fn: LogRecordFn): void {
298
+ this.print(console.error.bind(console), fn, FatalLevel);
299
+ }
300
+
301
+ log(level: LogLevel, fn: LogRecordFn): void {
302
+ switch (level) {
303
+ case "trace":
304
+ this.trace(fn);
305
+ break;
306
+ case "debug":
307
+ this.debug(fn);
308
+ break;
309
+ case "info":
310
+ this.info(fn);
311
+ break;
312
+ case "warn":
313
+ this.warn(fn);
314
+ break;
315
+ case "error":
316
+ this.error(fn);
317
+ break;
318
+ case "fatal":
319
+ this.fatal(fn);
320
+ break;
321
+ default:
322
+ throw new Error(`Unknown log level: ${level}`);
323
+ }
324
+ }
325
+ }
@@ -0,0 +1,134 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+ import { getLogger, stringifyBigInts } from "../logger.js";
17
+ describe("unpackError", () => {
18
+ let captured: string[] = [];
19
+
20
+ beforeEach(() => {
21
+ captured = [];
22
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
23
+ captured.push(String(args[0]));
24
+ });
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.restoreAllMocks();
29
+ });
30
+
31
+ function lastRecord(): Record<string, unknown> {
32
+ const raw = captured[captured.length - 1] ?? "";
33
+ return JSON.parse(raw) as Record<string, unknown>;
34
+ }
35
+
36
+ it("captures name from Error prototype chain", () => {
37
+ const logger = getLogger("error", "test");
38
+ const err = new TypeError("bad input");
39
+ logger.error(() => ({ err }));
40
+ const record = lastRecord();
41
+ const errData = record.errData as Record<string, unknown>;
42
+ expect(errData.name).toBe("TypeError");
43
+ });
44
+
45
+ it("captures message field", () => {
46
+ const logger = getLogger("error", "test");
47
+ const err = new Error("something went wrong");
48
+ logger.error(() => ({ err }));
49
+ const errData = lastRecord().errData as Record<string, unknown>;
50
+ expect(errData.message).toBe("something went wrong");
51
+ });
52
+
53
+ it("captures custom own properties from subclass errors", () => {
54
+ const logger = getLogger("error", "test");
55
+ const err = Object.assign(new Error("custom"), { statusCode: 404 });
56
+ logger.error(() => ({ err }));
57
+ const errData = lastRecord().errData as Record<string, unknown>;
58
+ expect(errData.statusCode).toBe(404);
59
+ });
60
+
61
+ it("recursively unpacks cause", () => {
62
+ const logger = getLogger("error", "test");
63
+ const cause = new Error("root cause");
64
+ const err = new Error("outer");
65
+ (err as unknown as Record<string, unknown>).cause = cause;
66
+ logger.error(() => ({ err }));
67
+ const errData = lastRecord().errData as Record<string, unknown>;
68
+ const causeData = errData.cause as Record<string, unknown>;
69
+ expect(causeData).toBeDefined();
70
+ });
71
+
72
+ it("suppresses stack when printStack is false (default)", () => {
73
+ const logger = getLogger("error", "test");
74
+ const err = new Error("test");
75
+ logger.error(() => ({ err }));
76
+ const errData = lastRecord().errData as Record<string, unknown>;
77
+ expect(errData.stack).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ describe("stringifyBigInts", () => {
82
+ it("should strigify big int", () => {
83
+ const bigInt = BigInt(12345678901234567890n);
84
+
85
+ const stringified = stringifyBigInts(bigInt);
86
+
87
+ expect(stringified).toEqual(bigInt.toString());
88
+ });
89
+
90
+ it("should strinfigy big int in object properties", () => {
91
+ const bigInt1 = BigInt(12345678901234567890n);
92
+ const bigInt2 = BigInt(23456789012345678901n);
93
+
94
+ const stringified = stringifyBigInts({
95
+ first: bigInt1,
96
+ second: {
97
+ inner: {
98
+ field: bigInt2,
99
+ },
100
+ },
101
+ third: "third",
102
+ });
103
+
104
+ expect(stringified).toEqual({
105
+ first: bigInt1.toString(),
106
+ second: {
107
+ inner: {
108
+ field: bigInt2.toString(),
109
+ },
110
+ },
111
+ third: "third",
112
+ });
113
+ });
114
+
115
+ it("should stringify big int inside array", () => {
116
+ const bigInt1 = BigInt(12345678901234567890n);
117
+ const bigInt2 = BigInt(23456789012345678901n);
118
+ const bigInt3 = BigInt(34567890123456789012n);
119
+
120
+ const stringified = stringifyBigInts([
121
+ bigInt1,
122
+ ["inner", bigInt2],
123
+ { name: "object", value: bigInt3 },
124
+ "third",
125
+ ]);
126
+
127
+ expect(stringified).toEqual([
128
+ bigInt1.toString(),
129
+ ["inner", bigInt2.toString()],
130
+ { name: "object", value: bigInt3.toString() },
131
+ "third",
132
+ ]);
133
+ });
134
+ });
@@ -0,0 +1,21 @@
1
+ {
2
+ "extends": "../../tsconfig.cjs.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist/cjs"
6
+ },
7
+ "include": [
8
+ "./src/**/*.ts",
9
+ "./src/**/*.json",
10
+ "./src/**/*.d.ts",
11
+ "./src/**/*.tsx"
12
+ ],
13
+ "references": [
14
+ {
15
+ "path": "../../dev/config/tsconfig.cjs.json"
16
+ },
17
+ {
18
+ "path": "../util/tsconfig.cjs.json"
19
+ }
20
+ ]
21
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "extends": "../../tsconfig.esm.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist"
6
+ },
7
+ "include": [
8
+ "src",
9
+ "src/**/*.json",
10
+ "src/**/*.ts",
11
+ "src/**/*.tsx",
12
+ "src/**/*.d.ts",
13
+ "src/**/.*.ts"
14
+ ],
15
+ "references": [
16
+ {
17
+ "path": "../../dev/config/tsconfig.json"
18
+ },
19
+ {
20
+ "path": "../util"
21
+ }
22
+ ]
23
+ }