@prosopo/logger 2.1.1 → 2.1.3

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.
@@ -1,441 +0,0 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
- import {
18
- getLogger,
19
- parseDirectives,
20
- parseLogLevel,
21
- resolveLevel,
22
- setGlobalDirectives,
23
- } from "../logger.js";
24
-
25
- // print() re-resolves PROSOPO_LOG_LEVEL directives at print time, so a stray
26
- // env value (e.g. PROSOPO_LOG_LEVEL=fatal) would otherwise suppress output that
27
- // these tests expect. Reset directives before every test to keep the suite
28
- // deterministic regardless of the developer's environment.
29
- beforeEach(() => {
30
- setGlobalDirectives("");
31
- });
32
-
33
- describe("unpackError", () => {
34
- let captured: string[] = [];
35
-
36
- beforeEach(() => {
37
- captured = [];
38
- vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
39
- captured.push(String(args[0]));
40
- });
41
- });
42
-
43
- afterEach(() => {
44
- vi.restoreAllMocks();
45
- });
46
-
47
- function lastRecord(): Record<string, unknown> {
48
- const raw = captured[captured.length - 1] ?? "";
49
- return JSON.parse(raw) as Record<string, unknown>;
50
- }
51
-
52
- it("captures name from Error prototype chain", () => {
53
- const logger = getLogger("error", "test");
54
- const err = new TypeError("bad input");
55
- logger.error(() => ({ err }));
56
- const record = lastRecord();
57
- const errData = record.errData as Record<string, unknown>;
58
- expect(errData.name).toBe("TypeError");
59
- });
60
-
61
- it("captures message field", () => {
62
- const logger = getLogger("error", "test");
63
- const err = new Error("something went wrong");
64
- logger.error(() => ({ err }));
65
- const errData = lastRecord().errData as Record<string, unknown>;
66
- expect(errData.message).toBe("something went wrong");
67
- });
68
-
69
- it("captures custom own properties from subclass errors", () => {
70
- const logger = getLogger("error", "test");
71
- const err = Object.assign(new Error("custom"), { statusCode: 404 });
72
- logger.error(() => ({ err }));
73
- const errData = lastRecord().errData as Record<string, unknown>;
74
- expect(errData.statusCode).toBe(404);
75
- });
76
-
77
- it("recursively unpacks cause", () => {
78
- const logger = getLogger("error", "test");
79
- const cause = new Error("root cause");
80
- const err = new Error("outer");
81
- (err as unknown as Record<string, unknown>).cause = cause;
82
- logger.error(() => ({ err }));
83
- const errData = lastRecord().errData as Record<string, unknown>;
84
- const causeData = errData.cause as Record<string, unknown>;
85
- expect(causeData).toBeDefined();
86
- });
87
-
88
- it("suppresses stack when printStack is false (default)", () => {
89
- const logger = getLogger("error", "test");
90
- const err = new Error("test");
91
- logger.error(() => ({ err }));
92
- const errData = lastRecord().errData as Record<string, unknown>;
93
- expect(errData.stack).toBeUndefined();
94
- });
95
- });
96
-
97
- describe("stringifyBigInts", () => {
98
- it("should stringify big int", () => {
99
- const bigInt = BigInt(12345678901234567890n);
100
-
101
- const stringified = stringifyBigInts(bigInt);
102
-
103
- expect(stringified).toEqual(bigInt.toString());
104
- });
105
-
106
- it("should strinfigy big int in object properties", () => {
107
- const bigInt1 = BigInt(12345678901234567890n);
108
- const bigInt2 = BigInt(23456789012345678901n);
109
-
110
- const stringified = stringifyBigInts({
111
- first: bigInt1,
112
- second: {
113
- inner: {
114
- field: bigInt2,
115
- },
116
- },
117
- third: "third",
118
- });
119
-
120
- expect(stringified).toEqual({
121
- first: bigInt1.toString(),
122
- second: {
123
- inner: {
124
- field: bigInt2.toString(),
125
- },
126
- },
127
- third: "third",
128
- });
129
- });
130
-
131
- it("should stringify big int inside array", () => {
132
- const bigInt1 = BigInt(12345678901234567890n);
133
- const bigInt2 = BigInt(23456789012345678901n);
134
- const bigInt3 = BigInt(34567890123456789012n);
135
-
136
- const stringified = stringifyBigInts([
137
- bigInt1,
138
- ["inner", bigInt2],
139
- { name: "object", value: bigInt3 },
140
- "third",
141
- ]);
142
-
143
- expect(stringified).toEqual([
144
- bigInt1.toString(),
145
- ["inner", bigInt2.toString()],
146
- { name: "object", value: bigInt3.toString() },
147
- "third",
148
- ]);
149
- });
150
- });
151
-
152
- describe("parseLogLevel", () => {
153
- it("parses a bare level", () => {
154
- expect(parseLogLevel("warn")).toBe("warn");
155
- });
156
-
157
- it("extracts the global level from a directive string", () => {
158
- expect(parseLogLevel("warn,database=trace")).toBe("warn");
159
- });
160
-
161
- it("returns the fallback for a directives-only string", () => {
162
- expect(parseLogLevel("database=trace", "info")).toBe("info");
163
- });
164
-
165
- it("returns the fallback for undefined", () => {
166
- expect(parseLogLevel(undefined, "info")).toBe("info");
167
- });
168
- });
169
-
170
- describe("parseDirectives", () => {
171
- it("parses a bare level as the global default (empty-string key)", () => {
172
- const directives = parseDirectives("debug");
173
- expect(directives.get("")).toBe("debug");
174
- });
175
-
176
- it("parses scope=level pairs", () => {
177
- const directives = parseDirectives("provider:db=debug,provider:api=trace");
178
- expect(directives.get("provider:db")).toBe("debug");
179
- expect(directives.get("provider:api")).toBe("trace");
180
- });
181
-
182
- it("ignores empty and invalid entries", () => {
183
- const directives = parseDirectives(" , provider:db=debug , bogus=nope ");
184
- expect(directives.get("provider:db")).toBe("debug");
185
- expect(directives.has("bogus")).toBe(false);
186
- });
187
-
188
- it("ignores an empty-scope entry like '=debug' (only a bare level sets the global default)", () => {
189
- const directives = parseDirectives("=debug");
190
- expect(directives.has("")).toBe(false);
191
- });
192
-
193
- it("does not let an empty-scope entry override a real scope's level", () => {
194
- const directives = parseDirectives("=debug,provider:db=warn");
195
- expect(directives.has("")).toBe(false);
196
- expect(directives.get("provider:db")).toBe("warn");
197
- });
198
-
199
- it("parses per-scope overrides", () => {
200
- const d = parseDirectives("warn,database=trace");
201
- expect(d.get("")).toBe("warn");
202
- expect(d.get("database")).toBe("trace");
203
- });
204
-
205
- it("parses directives-only (no global default)", () => {
206
- const d = parseDirectives("database=trace");
207
- expect(d.has("")).toBe(false);
208
- expect(d.get("database")).toBe("trace");
209
- });
210
- });
211
-
212
- describe("resolveLevel", () => {
213
- it("returns an exact scope match", () => {
214
- const directives = parseDirectives("provider:db=debug");
215
- expect(resolveLevel("provider:db", directives, "info")).toBe("debug");
216
- });
217
-
218
- it("returns the most specific matching prefix", () => {
219
- const directives = parseDirectives("provider=warn,provider:db=debug");
220
- expect(resolveLevel("provider:db:query", directives, "info")).toBe("debug");
221
- });
222
-
223
- it("falls back to the global default when no scope matches", () => {
224
- const directives = parseDirectives("other=trace,error");
225
- expect(resolveLevel("provider:db", directives, "info")).toBe("error");
226
- });
227
-
228
- it("falls back to the supplied fallback when nothing matches", () => {
229
- const directives = parseDirectives("other=trace");
230
- expect(resolveLevel("provider:db", directives, "info")).toBe("info");
231
- });
232
-
233
- it("matches the exact scope", () => {
234
- const d = parseDirectives("warn,database:mongo=trace");
235
- expect(resolveLevel("database:mongo", d, "info")).toBe("trace");
236
- });
237
-
238
- it("matches a prefix when exact scope has no entry", () => {
239
- const d = parseDirectives("warn,database=debug");
240
- expect(resolveLevel("database:mongo:queries", d, "info")).toBe("debug");
241
- });
242
-
243
- it("falls back to global default when no scope matches", () => {
244
- const d = parseDirectives("warn,database=debug");
245
- expect(resolveLevel("provider:request", d, "info")).toBe("warn");
246
- });
247
-
248
- it("returns fallback when directives are empty", () => {
249
- const d = parseDirectives("");
250
- expect(resolveLevel("any:scope", d, "info")).toBe("info");
251
- });
252
- });
253
-
254
- describe("Logger.with subscope + directives", () => {
255
- let captured: string[] = [];
256
-
257
- beforeEach(() => {
258
- captured = [];
259
- const capture = (...args: unknown[]): void => {
260
- captured.push(String(args[0]));
261
- };
262
- vi.spyOn(console, "debug").mockImplementation(capture);
263
- vi.spyOn(console, "info").mockImplementation(capture);
264
- });
265
-
266
- afterEach(() => {
267
- vi.restoreAllMocks();
268
- setGlobalDirectives("");
269
- });
270
-
271
- it("concatenates parent scope with subscope", () => {
272
- setGlobalDirectives("parent:child=debug");
273
- const child = getLogger("info", "parent").with({}, "child");
274
- child.debug(() => ({ msg: "hello" }));
275
- expect(captured.length).toBe(1);
276
- });
277
-
278
- it("uses the subscope directly when the parent scope is empty (no leading colon)", () => {
279
- setGlobalDirectives("admin=debug");
280
- const child = getLogger("info", "").with({}, "admin");
281
- child.debug(() => ({ msg: "hello" }));
282
- // If the scope were ":admin" the "admin" directive would not match and the
283
- // debug line would be suppressed.
284
- expect(captured.length).toBe(1);
285
- });
286
-
287
- it("re-resolves directives at print time rather than baking them in (no sticky level)", () => {
288
- setGlobalDirectives("admin=debug");
289
- const child = getLogger("info", "").with({}, "admin");
290
- // Directive removed after the child was created.
291
- setGlobalDirectives("");
292
- child.debug(() => ({ msg: "should be suppressed" }));
293
- expect(captured.length).toBe(0);
294
- // The inherited info level still applies.
295
- child.info(() => ({ msg: "visible" }));
296
- expect(captured.length).toBe(1);
297
- });
298
- });
299
-
300
- describe("setGlobalDirectives", () => {
301
- // Restore the env-derived directives so a failing assertion can't leak state
302
- // into later tests, without clobbering a non-empty PROSOPO_LOG_LEVEL.
303
- afterEach(() => {
304
- setGlobalDirectives(process.env.PROSOPO_LOG_LEVEL ?? "");
305
- });
306
-
307
- it("affects an existing logger's filtering at emit time", () => {
308
- const logger = getLogger("info", "test-scope");
309
- const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
310
- try {
311
- // Globally warn: a debug message from a logger created at info is suppressed.
312
- setGlobalDirectives("warn");
313
- logger.debug(() => ({ msg: "hidden" }));
314
- expect(debugSpy).not.toHaveBeenCalled();
315
-
316
- // Raising test-scope to trace at runtime lets the same logger emit debug.
317
- setGlobalDirectives("warn,test-scope=trace");
318
- logger.debug(() => ({ msg: "shown" }));
319
- expect(debugSpy).toHaveBeenCalledTimes(1);
320
- } finally {
321
- debugSpy.mockRestore();
322
- }
323
- });
324
- });
325
-
326
- describe("emitted record", () => {
327
- afterEach(() => {
328
- setGlobalDirectives(process.env.PROSOPO_LOG_LEVEL ?? "");
329
- });
330
-
331
- it("uses the message level rather than the logger's configured level", () => {
332
- setGlobalDirectives("trace");
333
- const logger = getLogger("info", "record-scope");
334
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
335
- try {
336
- logger.error(() => ({ msg: "boom" }));
337
- expect(errorSpy).toHaveBeenCalledTimes(1);
338
- const output = errorSpy.mock.calls[0]?.[0];
339
- expect(typeof output).toBe("string");
340
- const record: { level: string; scope: string; msg: string } = JSON.parse(
341
- output as string,
342
- );
343
- expect(record.level).toBe("error");
344
- expect(record.scope).toBe("record-scope");
345
- expect(record.msg).toBe("boom");
346
- } finally {
347
- errorSpy.mockRestore();
348
- }
349
- });
350
- });
351
-
352
- describe("Logger.with subscope", () => {
353
- it("appends subscope to parent scope", () => {
354
- const parent = getLogger("info", "provider");
355
- const child = parent.with({}, "request");
356
- expect(child.getScope()).toBe("provider:request");
357
- });
358
-
359
- it("does not produce a leading colon when parent scope is empty", () => {
360
- const parent = getLogger("info", "");
361
- const child = parent.with({}, "request");
362
- expect(child.getScope()).toBe("request");
363
- });
364
-
365
- it("trims surrounding whitespace from the subscope", () => {
366
- const parent = getLogger("info", "provider");
367
- const child = parent.with({}, " request ");
368
- expect(child.getScope()).toBe("provider:request");
369
- });
370
-
371
- it("treats a whitespace-only subscope as absent", () => {
372
- const parent = getLogger("info", "provider");
373
- const child = parent.with({}, " ");
374
- expect(child.getScope()).toBe("provider");
375
- });
376
-
377
- it("merges parent and child default data into emitted records", () => {
378
- setGlobalDirectives("trace");
379
- const parent = getLogger("info", "test").with({ requestId: "abc" });
380
- const child = parent.with({ extra: 1 });
381
- const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
382
- try {
383
- child.info(() => ({ data: { perCall: true } }));
384
- expect(infoSpy).toHaveBeenCalledTimes(1);
385
- const output = infoSpy.mock.calls[0]?.[0];
386
- expect(typeof output).toBe("string");
387
- const record: { scope: string; data: Record<string, unknown> } =
388
- JSON.parse(output as string);
389
- expect(record.scope).toBe("test");
390
- expect(record.data).toMatchObject({
391
- requestId: "abc",
392
- extra: 1,
393
- perCall: true,
394
- });
395
- } finally {
396
- infoSpy.mockRestore();
397
- setGlobalDirectives(process.env.PROSOPO_LOG_LEVEL ?? "");
398
- }
399
- });
400
-
401
- it("promotes requestId from default data to a top-level req_id field", () => {
402
- setGlobalDirectives("trace");
403
- const logger = getLogger("info", "test").with({ requestId: "abc-123" });
404
- const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
405
- try {
406
- logger.info(() => ({ msg: "hello" }));
407
- expect(infoSpy).toHaveBeenCalledTimes(1);
408
- const output = infoSpy.mock.calls[0]?.[0];
409
- const record: {
410
- req_id?: string;
411
- data?: { requestId?: string };
412
- } = JSON.parse(output as string);
413
- expect(record.req_id).toBe("abc-123");
414
- // Backwards compat: still available inside `data` for existing dashboards.
415
- expect(record.data?.requestId).toBe("abc-123");
416
- } finally {
417
- infoSpy.mockRestore();
418
- setGlobalDirectives(process.env.PROSOPO_LOG_LEVEL ?? "");
419
- }
420
- });
421
-
422
- it("omits req_id when the log record has no requestId in scope", () => {
423
- setGlobalDirectives("trace");
424
- const logger = getLogger("info", "test");
425
- const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
426
- try {
427
- logger.info(() => ({ msg: "hello", data: { foo: "bar" } }));
428
- expect(infoSpy).toHaveBeenCalledTimes(1);
429
- const output = infoSpy.mock.calls[0]?.[0];
430
- const record: {
431
- req_id?: string;
432
- data?: Record<string, unknown>;
433
- } = JSON.parse(output as string);
434
- expect(record.req_id).toBeUndefined();
435
- expect(record.data).toEqual({ foo: "bar" });
436
- } finally {
437
- infoSpy.mockRestore();
438
- setGlobalDirectives(process.env.PROSOPO_LOG_LEVEL ?? "");
439
- }
440
- });
441
- });
@@ -1,85 +0,0 @@
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
- // @vitest-environment jsdom
16
-
17
- import {
18
- type Mock,
19
- afterEach,
20
- beforeEach,
21
- describe,
22
- expect,
23
- it,
24
- vi,
25
- } from "vitest";
26
- import type { LogRecord } from "../logger.js";
27
- import { getLogger, setGlobalDirectives } from "../logger.js";
28
-
29
- // `inBrowser` is resolved once at module load, so this whole file runs under
30
- // jsdom to exercise the dev-tools branch of print() (objects are passed to the
31
- // console verbatim rather than being serialised to JSON).
32
-
33
- type ConsoleMock = Mock<(...args: unknown[]) => void>;
34
-
35
- let info: ConsoleMock;
36
- let error: ConsoleMock;
37
-
38
- beforeEach(() => {
39
- setGlobalDirectives("");
40
- info = vi.fn<(...args: unknown[]) => void>();
41
- error = vi.fn<(...args: unknown[]) => void>();
42
- vi.spyOn(console, "info").mockImplementation(info);
43
- vi.spyOn(console, "error").mockImplementation(error);
44
- });
45
-
46
- afterEach(() => {
47
- vi.restoreAllMocks();
48
- });
49
-
50
- describe("NativeLogger in a browser", () => {
51
- it("logs the message and the record separately so dev tools can expand it", () => {
52
- getLogger("info", "scope").info((): LogRecord => ({ msg: "hello" }));
53
-
54
- expect(info).toHaveBeenCalledWith(
55
- "hello",
56
- expect.objectContaining({ msg: "hello", scope: "scope", level: "info" }),
57
- );
58
- });
59
-
60
- it("falls back to the error message when there is no msg", () => {
61
- getLogger("info", "scope").error(
62
- (): LogRecord => ({ err: new Error("boom") }),
63
- );
64
-
65
- expect(error).toHaveBeenCalledWith(
66
- "boom",
67
- expect.objectContaining({ err: "boom" }),
68
- );
69
- });
70
-
71
- it("logs the bare record when there is neither a message nor an error", () => {
72
- getLogger("info", "scope").info((): LogRecord => ({ data: { a: 1 } }));
73
-
74
- expect(info).toHaveBeenCalledWith(
75
- expect.objectContaining({ data: { a: 1 }, scope: "scope" }),
76
- );
77
- expect(info.mock.calls[0]).toHaveLength(1);
78
- });
79
-
80
- it("does not serialise the record to a string", () => {
81
- getLogger("info", "scope").info((): LogRecord => ({ msg: "hello" }));
82
-
83
- expect(typeof info.mock.calls[0]?.[1]).toBe("object");
84
- });
85
- });