@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.
package/src/logger.ts DELETED
@@ -1,471 +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
-
17
- export type LogObject = object;
18
- export type LogRecord = {
19
- err?: unknown;
20
- data?: LogObject;
21
- msg?: string;
22
- };
23
- export type LogRecordFn = () => LogRecord;
24
-
25
- export type Logger = {
26
- setLogLevel(level: LogLevel): void;
27
- getLogLevel(): LogLevel;
28
- getScope(): string;
29
- info(fn: LogRecordFn): void;
30
- debug(fn: LogRecordFn): void;
31
- trace(fn: LogRecordFn): void;
32
- warn(fn: LogRecordFn): void;
33
- error(fn: LogRecordFn): void;
34
- fatal(fn: LogRecordFn): void;
35
- log(level: LogLevel, fn: LogRecordFn): void;
36
- /**
37
- * Creates a child logger with merged default data and an optional subscope appended to the
38
- * current scope (e.g. parent "database" + subscope "queries" → "database:queries").
39
- * The child's configured level is snapshotted at creation by resolving PROSOPO_LOG_LEVEL
40
- * directives for the new scope, falling back to the parent's level when no directive matches.
41
- * At emit time `print()` re-resolves directives for the scope, using the child's own configured
42
- * level (this snapshot) as the fallback — so later directive changes are honoured, but a direct
43
- * `setLogLevel()` on the child overrides the snapshot.
44
- */
45
- with(obj: LogObject, subscope?: string): Logger;
46
- getPretty(): boolean;
47
- setPretty(pretty: boolean): void;
48
- getPrintStack(): boolean;
49
- setPrintStack(printStack: boolean): void;
50
- getFormat(): Format;
51
- setFormat(format: Format): void;
52
- };
53
-
54
- // Level string constants are internal to this module; consumers use the
55
- // `LogLevel` enum/type or the string literals it parses.
56
- const InfoLevel = "info";
57
- const DebugLevel = "debug";
58
- const TraceLevel = "trace";
59
- const WarnLevel = "warn";
60
- const ErrorLevel = "error";
61
- const FatalLevel = "fatal";
62
-
63
- const LogLevels = [
64
- InfoLevel,
65
- DebugLevel,
66
- TraceLevel,
67
- WarnLevel,
68
- ErrorLevel,
69
- FatalLevel,
70
- ] as const;
71
-
72
- export type LogLevel = (typeof LogLevels)[number];
73
-
74
- /**
75
- * Shaped like the zod enum it replaced, because `LogLevel.enum.info` and
76
- * `LogLevel.options` are what callers across the repo already write. zod itself
77
- * is 14KB gzipped and this module is on the widget's critical path, which is a
78
- * poor trade for validating six strings.
79
- */
80
- export const LogLevel = {
81
- enum: {
82
- [InfoLevel]: InfoLevel,
83
- [DebugLevel]: DebugLevel,
84
- [TraceLevel]: TraceLevel,
85
- [WarnLevel]: WarnLevel,
86
- [ErrorLevel]: ErrorLevel,
87
- [FatalLevel]: FatalLevel,
88
- },
89
- options: LogLevels,
90
- } as const;
91
-
92
- const isLogLevel = (value: string): value is LogLevel =>
93
- (LogLevels as readonly string[]).includes(value);
94
-
95
- const logLevelMap: Record<LogLevel, number> = {
96
- [TraceLevel]: 0,
97
- [DebugLevel]: 1,
98
- [InfoLevel]: 2,
99
- [WarnLevel]: 3,
100
- [ErrorLevel]: 4,
101
- [FatalLevel]: 5,
102
- };
103
-
104
- export function parseLogLevel(
105
- level: string | undefined,
106
- or: LogLevel = InfoLevel,
107
- ): LogLevel {
108
- if (!level) return or;
109
- // Handle directive strings like "warn,database=trace" — extract the bare global level.
110
- for (const part of level.split(",")) {
111
- const trimmed = part.trim();
112
- if (trimmed && !trimmed.includes("=")) {
113
- if (isLogLevel(trimmed)) return trimmed;
114
- }
115
- }
116
- return or;
117
- }
118
-
119
- // ---------------------------------------------------------------------------
120
- // Directive-based filtering
121
- //
122
- // PROSOPO_LOG_LEVEL supports both a bare level and a comma-separated directive
123
- // string with optional per-scope overrides, e.g.:
124
- // "warn" – global floor
125
- // "warn,database=trace" – global warn, database:* gets trace
126
- // "database=trace,http=debug" – per-scope only, no global default
127
- //
128
- // Scope segments are separated by ":" mirroring module paths, e.g.
129
- // "provider:db=debug" matches any logger whose scope starts with "provider:db".
130
- // ---------------------------------------------------------------------------
131
-
132
- export type Directives = Map<string, LogLevel>;
133
-
134
- /**
135
- * Parse a PROSOPO_LOG_LEVEL directive string into a scope→level map.
136
- * An entry with an empty-string key represents the global default.
137
- */
138
- export function parseDirectives(raw: string): Directives {
139
- const map: Directives = new Map();
140
- for (const part of raw.split(",")) {
141
- const trimmed = part.trim();
142
- if (!trimmed) continue;
143
- const eqIdx = trimmed.indexOf("=");
144
- if (eqIdx === -1) {
145
- if (isLogLevel(trimmed)) map.set("", trimmed);
146
- } else {
147
- const scope = trimmed.slice(0, eqIdx).trim();
148
- // Ignore entries with an empty scope (e.g. "=debug") — only a bare
149
- // level (no "=") may set the global default.
150
- if (!scope) continue;
151
- const level = trimmed.slice(eqIdx + 1).trim();
152
- if (isLogLevel(level)) map.set(scope, level);
153
- }
154
- }
155
- return map;
156
- }
157
-
158
- /**
159
- * Find the most specific directive that matches the given scope.
160
- * Walks from the full scope up to the empty-string global default.
161
- * Returns the fallback if nothing matches.
162
- */
163
- export function resolveLevel(
164
- scope: string,
165
- directives: Directives,
166
- fallback: LogLevel,
167
- ): LogLevel {
168
- if (directives.has(scope)) return directives.get(scope) as LogLevel;
169
- const parts = scope.split(":");
170
- for (let i = parts.length - 1; i > 0; i--) {
171
- const prefix = parts.slice(0, i).join(":");
172
- if (directives.has(prefix)) return directives.get(prefix) as LogLevel;
173
- }
174
- if (directives.has("")) return directives.get("") as LogLevel;
175
- return fallback;
176
- }
177
-
178
- let _globalDirectives: Directives | undefined;
179
-
180
- function getGlobalDirectives(): Directives {
181
- if (!_globalDirectives) {
182
- const raw =
183
- typeof process !== "undefined"
184
- ? (process.env?.PROSOPO_LOG_LEVEL ?? "")
185
- : "";
186
- _globalDirectives = parseDirectives(raw);
187
- }
188
- return _globalDirectives;
189
- }
190
-
191
- /**
192
- * Override the global directives at runtime (useful in tests or at app startup).
193
- * Accepts the same directive string as PROSOPO_LOG_LEVEL.
194
- */
195
- export function setGlobalDirectives(raw: string): void {
196
- _globalDirectives = parseDirectives(raw);
197
- }
198
-
199
- // Create a new logger with the given level and scope
200
- export function getLogger(logLevel: LogLevel, scope: string): Logger {
201
- const logger = new NativeLogger(scope);
202
- logger.setLogLevel(logLevel);
203
- return logger;
204
- }
205
-
206
- const inBrowser =
207
- typeof window !== "undefined" && typeof window.document !== "undefined";
208
-
209
- const FormatJson = "json";
210
- const FormatPlain = "plain";
211
- export type Format = typeof FormatJson | typeof FormatPlain;
212
-
213
- /**
214
- * Native logger which uses console.log, console.error, etc, without any libraries.
215
- */
216
- export class NativeLogger implements Logger {
217
- // the default data to be logged
218
- // this provides utility for adding properties to every log message
219
- private defaultData: LogObject | undefined;
220
- private level: LogLevel;
221
- private pretty = 0; // pretty print indentation level - 0 = no pretty print, 2 = 2 spaces, etc
222
- private printStack = false; // whether to print the stack trace in the log
223
- private format: Format = FormatJson;
224
-
225
- constructor(
226
- private scope: string,
227
- private levelMap: Record<LogLevel, number> = logLevelMap,
228
- ) {
229
- this.level = InfoLevel; // default log level
230
- }
231
-
232
- setFormat(format: Format): void {
233
- this.format = format;
234
- if (this.format !== FormatJson) {
235
- throw new Error("Only JSON format implemented for now"); // for performance reasons
236
- }
237
- }
238
-
239
- getFormat(): Format {
240
- return this.format;
241
- }
242
-
243
- with(obj: LogObject, subscope?: string): Logger {
244
- // Trim so a whitespace-only subscope is treated as absent and the
245
- // resulting scope matches the trimmed keys produced by parseDirectives().
246
- const trimmedSubscope = subscope?.trim();
247
- const newScope = trimmedSubscope
248
- ? this.scope
249
- ? `${this.scope}:${trimmedSubscope}`
250
- : trimmedSubscope
251
- : this.scope;
252
- const newLogger = new NativeLogger(newScope, this.levelMap);
253
- // Avoid allocating an empty defaultData object when scoping only
254
- // (e.g. with({}, "subscope")). Reuse the parent reference when obj has no
255
- // keys, and skip merging entirely when neither side has data.
256
- const hasObjData = Object.keys(obj).length > 0;
257
- if (hasObjData) {
258
- newLogger.defaultData = { ...this.defaultData, ...obj };
259
- } else if (this.defaultData) {
260
- newLogger.defaultData = this.defaultData;
261
- }
262
- newLogger.setPretty(this.getPretty());
263
- newLogger.setPrintStack(this.getPrintStack());
264
- // Inherit the parent's configured level as the fallback only. Directives
265
- // are applied dynamically at print time (see print()), so we must NOT bake
266
- // the currently-resolved level in here — otherwise a directive active now
267
- // would stick to this child even after setGlobalDirectives() clears it.
268
- newLogger.setLogLevel(this.getLogLevel());
269
- return newLogger;
270
- }
271
-
272
- getPrintStack(): boolean {
273
- return this.printStack;
274
- }
275
-
276
- setPrintStack(printStack: boolean): void {
277
- this.printStack = printStack;
278
- }
279
-
280
- getPretty(): boolean {
281
- return this.pretty > 0;
282
- }
283
-
284
- setPretty(pretty: boolean): void {
285
- this.pretty = pretty ? 2 : 0;
286
- }
287
-
288
- getScope(): string {
289
- return this.scope;
290
- }
291
-
292
- setLogLevel(level: LogLevel): void {
293
- this.level = level;
294
- }
295
-
296
- getLogLevel(): LogLevel {
297
- return this.level;
298
- }
299
-
300
- private unpackError(err: Error | object): {
301
- msg: string;
302
- data: Record<string, unknown>;
303
- } {
304
- const e = err as Record<string, unknown>;
305
- const data: Record<string, unknown> = {};
306
-
307
- for (const key of Object.getOwnPropertyNames(err)) {
308
- if (!this.printStack && (key === "stack" || key === "stacktrace"))
309
- continue;
310
- data[key] = e[key];
311
- }
312
-
313
- if (!data.name) {
314
- data.name = (err as { name?: unknown }).name ?? "Error";
315
- }
316
-
317
- // Recursively unpack the cause chain
318
- const cause = e.cause;
319
- if (cause !== undefined) {
320
- if (
321
- cause instanceof Error ||
322
- (typeof cause === "object" && cause !== null)
323
- ) {
324
- data.cause = this.unpackError(cause as Error | object);
325
- } else {
326
- data.cause = cause;
327
- }
328
- }
329
-
330
- // Prefer translationKey when present (e.g. ProsopoBaseError) so the
331
- // top-level `err` field is locale-stable and queryable.
332
- const msg = String(e.translationKey ?? e.message ?? e.msg ?? "");
333
- return { msg, data };
334
- }
335
-
336
- private print(
337
- dest: (...args: unknown[]) => void,
338
- fn: LogRecordFn,
339
- level: LogLevel,
340
- ): void {
341
- // Re-resolve at print time so directives set after construction are respected.
342
- const effectiveLevelNum =
343
- this.levelMap[
344
- resolveLevel(this.scope, getGlobalDirectives(), this.level)
345
- ];
346
- if (this.levelMap[level] < effectiveLevelNum) {
347
- return; // skip logging if the level is below the effective threshold
348
- }
349
- const ts = new Date().toISOString();
350
- // populate the log fields using the fn
351
- let { data, msg, err } = fn();
352
- let errMsg: string | undefined;
353
- let errData: Record<string, unknown> | undefined;
354
- if (err) {
355
- if (err instanceof Error || typeof err === "object") {
356
- // if it's an instance of Error, unpack the standard fields (e.g. message, name, stack, etc)
357
- const result = this.unpackError(err);
358
- errMsg = result.msg;
359
- errData = result.data;
360
- } else {
361
- // primitive
362
- errMsg = String(err);
363
- }
364
- }
365
- // add any default data to the data object
366
- if (this.defaultData) {
367
- data = { ...this.defaultData, ...data };
368
- }
369
- // Promote `requestId` (set via `.with({requestId})` in the request
370
- // logger middleware) to a top-level `req_id` on the emitted record.
371
- // OpenObserve indexes top-level fields as their own columns, so this
372
- // makes `WHERE req_id = '…'` cheap and lets us correlate to Caddy's
373
- // `X-Request-ID` without a schema alias per stream. Keep the copy in
374
- // `data.requestId` for backwards compat with existing dashboards.
375
- const dataMaybeRequestId = data as { requestId?: unknown } | undefined;
376
- const reqId =
377
- dataMaybeRequestId && typeof dataMaybeRequestId.requestId === "string"
378
- ? dataMaybeRequestId.requestId
379
- : undefined;
380
- const baseRecord: {
381
- scope: string;
382
- ts: string;
383
- level: LogLevel;
384
- req_id?: string;
385
- data?: LogObject;
386
- msg?: string;
387
- err?: string;
388
- errData?: Record<string, unknown>;
389
- } = { scope: this.scope, ts, level };
390
- if (reqId) {
391
- baseRecord.req_id = reqId;
392
- }
393
- if (data) {
394
- baseRecord.data = data;
395
- }
396
- if (msg) {
397
- baseRecord.msg = msg;
398
- }
399
- if (errMsg) {
400
- baseRecord.err = errMsg;
401
- }
402
- if (errData) {
403
- baseRecord.errData = errData;
404
- }
405
-
406
- if (inBrowser) {
407
- // no need to convert to json, dev tools will handle it
408
- if (msg || errMsg) {
409
- // log the log record object separately to the message to utilize browser dev tools
410
- dest(msg || errMsg, baseRecord);
411
- } else {
412
- dest(baseRecord);
413
- }
414
- } else {
415
- // conversion to avoid "TypeError: Do not know how to serialize a BigInt" in JSON.stringify
416
- const logRecord = stringifyBigInts(baseRecord) as object;
417
-
418
- const output = JSON.stringify(logRecord, null, this.pretty);
419
- dest(output);
420
- }
421
- }
422
-
423
- info(fn: LogRecordFn): void {
424
- this.print(console.info.bind(console), fn, InfoLevel);
425
- }
426
-
427
- debug(fn: LogRecordFn): void {
428
- this.print(console.debug.bind(console), fn, DebugLevel);
429
- }
430
-
431
- trace(fn: LogRecordFn): void {
432
- this.print(console.trace.bind(console), fn, TraceLevel);
433
- }
434
-
435
- warn(fn: LogRecordFn): void {
436
- this.print(console.warn.bind(console), fn, WarnLevel);
437
- }
438
-
439
- error(fn: LogRecordFn): void {
440
- this.print(console.error.bind(console), fn, ErrorLevel);
441
- }
442
-
443
- fatal(fn: LogRecordFn): void {
444
- this.print(console.error.bind(console), fn, FatalLevel);
445
- }
446
-
447
- log(level: LogLevel, fn: LogRecordFn): void {
448
- switch (level) {
449
- case "trace":
450
- this.trace(fn);
451
- break;
452
- case "debug":
453
- this.debug(fn);
454
- break;
455
- case "info":
456
- this.info(fn);
457
- break;
458
- case "warn":
459
- this.warn(fn);
460
- break;
461
- case "error":
462
- this.error(fn);
463
- break;
464
- case "fatal":
465
- this.fatal(fn);
466
- break;
467
- default:
468
- throw new Error(`Unknown log level: ${level}`);
469
- }
470
- }
471
- }