@oneuptime/common 13.0.3 → 13.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.
@@ -0,0 +1,597 @@
1
+ import ConfigLogLevel from "../../../Server/Types/ConfigLogLevel";
2
+ import { REDACTED } from "../../../Server/Utils/LogRedaction";
3
+ import { SeverityNumber } from "@opentelemetry/api-logs";
4
+ import {
5
+ afterEach,
6
+ beforeEach,
7
+ describe,
8
+ expect,
9
+ jest,
10
+ test,
11
+ } from "@jest/globals";
12
+
13
+ /*
14
+ * Core behaviour of Server/Utils/Logger that the credential-leak and
15
+ * fault-demotion suites do not pin: level filtering per method, how bodies
16
+ * are serialised for the ring buffer and the OTel exporter, attribute
17
+ * sanitisation and merging with the ambient TelemetryContext, the ring
18
+ * buffer's trimming and `limit`, a missing / throwing telemetry logger, and
19
+ * getLogAttributesFromRequest.
20
+ *
21
+ * Telemetry is mocked; console methods are silenced spies; time is frozen so
22
+ * the ring buffer timestamps are deterministic.
23
+ */
24
+
25
+ interface EmittedRecord {
26
+ body: string;
27
+ severityNumber: SeverityNumber;
28
+ attributes?: Record<string, string | number | boolean> | undefined;
29
+ }
30
+
31
+ interface MockTelemetryState {
32
+ mode: "collect" | "null" | "throw";
33
+ emitted: Array<EmittedRecord>;
34
+ }
35
+
36
+ const mockTelemetryState: MockTelemetryState = {
37
+ mode: "collect",
38
+ emitted: [],
39
+ };
40
+
41
+ jest.mock("../../../Server/Utils/Telemetry", () => {
42
+ return {
43
+ __esModule: true,
44
+ default: {
45
+ getLogger: (): unknown => {
46
+ if (mockTelemetryState.mode === "null") {
47
+ return null;
48
+ }
49
+
50
+ if (mockTelemetryState.mode === "throw") {
51
+ throw new Error("telemetry exploded");
52
+ }
53
+
54
+ return {
55
+ emit: (record: EmittedRecord): void => {
56
+ mockTelemetryState.emitted.push(record);
57
+ },
58
+ };
59
+ },
60
+ },
61
+ };
62
+ });
63
+
64
+ import logger, {
65
+ getLogAttributesFromRequest,
66
+ LogAttributes,
67
+ RecentLogEntry,
68
+ } from "../../../Server/Utils/Logger";
69
+ import TelemetryContext from "../../../Server/Utils/Telemetry/TelemetryContext";
70
+
71
+ type LogMethod = "info" | "warn" | "error" | "debug" | "trace";
72
+ type ConsoleMethod = "info" | "warn" | "error" | "debug" | "trace";
73
+
74
+ interface ConsoleSpy {
75
+ mock: { calls: Array<Array<unknown>> };
76
+ }
77
+
78
+ const FIXED_NOW: Date = new Date("2026-01-15T10:20:30.000Z");
79
+
80
+ let consoleSpies: Record<ConsoleMethod, ConsoleSpy>;
81
+
82
+ function resetRecentLogs(): void {
83
+ (logger as unknown as { recentLogs: Array<RecentLogEntry> }).recentLogs = [];
84
+ }
85
+
86
+ function setLevel(level: ConfigLogLevel): void {
87
+ jest.spyOn(logger, "getLogLevel").mockReturnValue(level);
88
+ }
89
+
90
+ function lastEmitted(): EmittedRecord {
91
+ const record: EmittedRecord | undefined =
92
+ mockTelemetryState.emitted[mockTelemetryState.emitted.length - 1];
93
+
94
+ if (!record) {
95
+ throw new Error("nothing was emitted");
96
+ }
97
+
98
+ return record;
99
+ }
100
+
101
+ function totalConsoleCalls(): number {
102
+ return Object.values(consoleSpies).reduce(
103
+ (sum: number, spy: ConsoleSpy): number => {
104
+ return sum + spy.mock.calls.length;
105
+ },
106
+ 0,
107
+ );
108
+ }
109
+
110
+ beforeEach((): void => {
111
+ jest.useFakeTimers();
112
+ jest.setSystemTime(FIXED_NOW);
113
+
114
+ mockTelemetryState.mode = "collect";
115
+ mockTelemetryState.emitted.length = 0;
116
+ resetRecentLogs();
117
+
118
+ const silence: () => undefined = (): undefined => {
119
+ return undefined;
120
+ };
121
+
122
+ consoleSpies = {
123
+ info: jest.spyOn(console, "info").mockImplementation(silence),
124
+ warn: jest.spyOn(console, "warn").mockImplementation(silence),
125
+ error: jest.spyOn(console, "error").mockImplementation(silence),
126
+ debug: jest.spyOn(console, "debug").mockImplementation(silence),
127
+ trace: jest.spyOn(console, "trace").mockImplementation(silence),
128
+ };
129
+ });
130
+
131
+ afterEach((): void => {
132
+ jest.restoreAllMocks();
133
+ jest.useRealTimers();
134
+ resetRecentLogs();
135
+ });
136
+
137
+ describe("Logger level filtering", () => {
138
+ /*
139
+ * For every configured level, which logger methods produce output. error()
140
+ * is on for every level except OFF; debug and trace need DEBUG.
141
+ */
142
+ const matrix: Array<{ level: ConfigLogLevel; enabled: Array<LogMethod> }> = [
143
+ {
144
+ level: ConfigLogLevel.DEBUG,
145
+ enabled: ["info", "warn", "error", "debug", "trace"],
146
+ },
147
+ { level: ConfigLogLevel.INFO, enabled: ["info", "warn", "error"] },
148
+ { level: ConfigLogLevel.WARN, enabled: ["warn", "error"] },
149
+ { level: ConfigLogLevel.ERROR, enabled: ["error"] },
150
+ { level: ConfigLogLevel.OFF, enabled: [] },
151
+ ];
152
+
153
+ const allMethods: Array<LogMethod> = [
154
+ "info",
155
+ "warn",
156
+ "error",
157
+ "debug",
158
+ "trace",
159
+ ];
160
+
161
+ interface FilterCase {
162
+ name: string;
163
+ level: ConfigLogLevel;
164
+ method: LogMethod;
165
+ shouldLog: boolean;
166
+ }
167
+
168
+ const cases: Array<FilterCase> = [];
169
+
170
+ for (const row of matrix) {
171
+ for (const method of allMethods) {
172
+ const shouldLog: boolean = row.enabled.includes(method);
173
+ cases.push({
174
+ name: `LOG_LEVEL=${row.level}: ${method}() ${shouldLog ? "logs" : "is silent"}`,
175
+ level: row.level,
176
+ method: method,
177
+ shouldLog: shouldLog,
178
+ });
179
+ }
180
+ }
181
+
182
+ test.each(cases)("$name", (filterCase: FilterCase) => {
183
+ setLevel(filterCase.level);
184
+
185
+ logger[filterCase.method](`message from ${filterCase.method}`);
186
+
187
+ if (filterCase.shouldLog) {
188
+ expect(consoleSpies[filterCase.method].mock.calls).toEqual([
189
+ [`message from ${filterCase.method}`],
190
+ ]);
191
+ expect(totalConsoleCalls()).toBe(1);
192
+ expect(logger.getRecentLogs().length).toBe(1);
193
+ expect(mockTelemetryState.emitted.length).toBe(1);
194
+ } else {
195
+ expect(totalConsoleCalls()).toBe(0);
196
+ expect(logger.getRecentLogs()).toEqual([]);
197
+ expect(mockTelemetryState.emitted).toEqual([]);
198
+ }
199
+ });
200
+
201
+ test("each method records its own level name and exports its own severity", () => {
202
+ setLevel(ConfigLogLevel.DEBUG);
203
+
204
+ logger.info("i");
205
+ logger.warn("w");
206
+ logger.error("e");
207
+ logger.debug("d");
208
+ logger.trace("t");
209
+
210
+ expect(
211
+ logger.getRecentLogs().map((entry: RecentLogEntry): string => {
212
+ return entry.level;
213
+ }),
214
+ ).toEqual(["INFO", "WARN", "ERROR", "DEBUG", "TRACE"]);
215
+
216
+ expect(
217
+ mockTelemetryState.emitted.map((r: EmittedRecord): SeverityNumber => {
218
+ return r.severityNumber;
219
+ }),
220
+ ).toEqual([
221
+ SeverityNumber.INFO,
222
+ SeverityNumber.WARN,
223
+ SeverityNumber.ERROR,
224
+ SeverityNumber.DEBUG,
225
+ // trace is exported at DEBUG severity
226
+ SeverityNumber.DEBUG,
227
+ ]);
228
+ });
229
+ });
230
+
231
+ describe("Logger.getLogLevel", () => {
232
+ const originalLogLevel: string | undefined = process.env["LOG_LEVEL"];
233
+
234
+ afterEach((): void => {
235
+ if (originalLogLevel === undefined) {
236
+ delete process.env["LOG_LEVEL"];
237
+ } else {
238
+ process.env["LOG_LEVEL"] = originalLogLevel;
239
+ }
240
+ });
241
+
242
+ function loadLoggerWith(
243
+ environmentOverride: Record<string, unknown>,
244
+ ): typeof logger {
245
+ let loaded: typeof logger | undefined;
246
+
247
+ jest.isolateModules((): void => {
248
+ jest.doMock("../../../Server/EnvironmentConfig", () => {
249
+ return {
250
+ ...(jest.requireActual("../../../Server/EnvironmentConfig") as Record<
251
+ string,
252
+ unknown
253
+ >),
254
+ ...environmentOverride,
255
+ };
256
+ });
257
+ loaded = (
258
+ jest.requireActual("../../../Server/Utils/Logger") as {
259
+ default: typeof logger;
260
+ }
261
+ ).default;
262
+ });
263
+
264
+ jest.dontMock("../../../Server/EnvironmentConfig");
265
+
266
+ return loaded!;
267
+ }
268
+
269
+ test("returns the configured LOG_LEVEL", () => {
270
+ expect(
271
+ loadLoggerWith({ LogLevel: ConfigLogLevel.WARN }).getLogLevel(),
272
+ ).toBe(ConfigLogLevel.WARN);
273
+ });
274
+
275
+ test("defaults to INFO when no level is configured", () => {
276
+ expect(loadLoggerWith({ LogLevel: undefined }).getLogLevel()).toBe(
277
+ ConfigLogLevel.INFO,
278
+ );
279
+ });
280
+
281
+ test("reads LOG_LEVEL from the environment at load time", () => {
282
+ process.env["LOG_LEVEL"] = ConfigLogLevel.DEBUG;
283
+ expect(loadLoggerWith({}).getLogLevel()).toBe(ConfigLogLevel.DEBUG);
284
+
285
+ delete process.env["LOG_LEVEL"];
286
+ expect(loadLoggerWith({}).getLogLevel()).toBe(ConfigLogLevel.INFO);
287
+ });
288
+ });
289
+
290
+ describe("Logger body serialisation", () => {
291
+ beforeEach((): void => {
292
+ setLevel(ConfigLogLevel.DEBUG);
293
+ });
294
+
295
+ test("a string is recorded and exported verbatim with a frozen timestamp", () => {
296
+ logger.info("plain message");
297
+
298
+ expect(logger.getRecentLogs()).toEqual([
299
+ {
300
+ time: "2026-01-15T10:20:30.000Z",
301
+ level: "INFO",
302
+ message: "plain message",
303
+ },
304
+ ]);
305
+ expect(lastEmitted()).toEqual({
306
+ body: "plain message",
307
+ severityNumber: SeverityNumber.INFO,
308
+ });
309
+ });
310
+
311
+ test("an object is printed as an object but recorded and exported as JSON", () => {
312
+ const body: { a: number; nested: { b: string } } = {
313
+ a: 1,
314
+ nested: { b: "two" },
315
+ };
316
+
317
+ logger.info(body);
318
+
319
+ expect(consoleSpies.info.mock.calls[0]![0]).toEqual(body);
320
+ expect(typeof consoleSpies.info.mock.calls[0]![0]).toBe("object");
321
+ expect(logger.getRecentLogs()[0]!.message).toBe(
322
+ '{"a":1,"nested":{"b":"two"}}',
323
+ );
324
+ expect(lastEmitted().body).toBe('{"a":1,"nested":{"b":"two"}}');
325
+ });
326
+
327
+ test("an Error without secrets reaches console as the same Error and is recorded by message", () => {
328
+ const err: Error = new Error("disk full");
329
+
330
+ logger.warn(err);
331
+
332
+ expect(consoleSpies.warn.mock.calls[0]![0]).toBe(err);
333
+ expect(logger.getRecentLogs()[0]!.message).toBe("disk full");
334
+ expect(lastEmitted().body).toBe("disk full");
335
+ });
336
+
337
+ test("undefined serialises to an empty body instead of throwing", () => {
338
+ logger.info(undefined);
339
+
340
+ expect(logger.getRecentLogs()[0]!.message).toBe("");
341
+ expect(lastEmitted().body).toBe("");
342
+ });
343
+
344
+ test("numbers and booleans serialise as JSON literals", () => {
345
+ logger.info(42);
346
+ logger.info(false);
347
+
348
+ expect(
349
+ logger.getRecentLogs().map((e: RecentLogEntry): string => {
350
+ return e.message;
351
+ }),
352
+ ).toEqual(["42", "false"]);
353
+ });
354
+
355
+ test("a body that cannot be walked is replaced by the redaction marker, never passed on raw", () => {
356
+ const hostile: Record<string, unknown> = new Proxy(
357
+ {},
358
+ {
359
+ ownKeys: (): Array<string> => {
360
+ throw new Error("no keys for you");
361
+ },
362
+ get: (): never => {
363
+ throw new Error("no props for you");
364
+ },
365
+ },
366
+ );
367
+
368
+ expect((): void => {
369
+ logger.info(hostile);
370
+ }).not.toThrow();
371
+
372
+ expect(consoleSpies.info.mock.calls[0]![0]).toBe(REDACTED);
373
+ expect(logger.getRecentLogs()[0]!.message).toBe(REDACTED);
374
+ });
375
+
376
+ test("serializeLogBody redacts and serialises in one step", () => {
377
+ expect(logger.serializeLogBody("hello")).toBe("hello");
378
+ expect(logger.serializeLogBody({ x: [1, 2] })).toBe('{"x":[1,2]}');
379
+ expect(logger.serializeLogBody(new Error("boom"))).toBe("boom");
380
+ expect(logger.serializeLogBody({ password: "hunter2" })).not.toContain(
381
+ "hunter2",
382
+ );
383
+ });
384
+
385
+ test("messages longer than 4000 characters are truncated in the ring buffer only", () => {
386
+ const long: string = "x".repeat(4500);
387
+
388
+ logger.info(long);
389
+
390
+ const recorded: string = logger.getRecentLogs()[0]!.message;
391
+ expect(recorded).toBe(`${"x".repeat(4000)}… (truncated)`);
392
+ expect(lastEmitted().body).toBe(long);
393
+ expect(consoleSpies.info.mock.calls[0]![0]).toBe(long);
394
+ });
395
+ });
396
+
397
+ describe("Logger recent-log ring buffer", () => {
398
+ beforeEach((): void => {
399
+ setLevel(ConfigLogLevel.INFO);
400
+ });
401
+
402
+ test("getRecentLogs returns a newest-last copy, optionally limited", () => {
403
+ logger.info("one");
404
+ logger.info("two");
405
+ logger.info("three");
406
+
407
+ const messages: (entries: Array<RecentLogEntry>) => Array<string> = (
408
+ entries: Array<RecentLogEntry>,
409
+ ): Array<string> => {
410
+ return entries.map((e: RecentLogEntry): string => {
411
+ return e.message;
412
+ });
413
+ };
414
+
415
+ expect(messages(logger.getRecentLogs())).toEqual(["one", "two", "three"]);
416
+ expect(messages(logger.getRecentLogs(2))).toEqual(["two", "three"]);
417
+ expect(messages(logger.getRecentLogs(3))).toEqual(["one", "two", "three"]);
418
+ expect(messages(logger.getRecentLogs(99))).toEqual(["one", "two", "three"]);
419
+ // 0 means "no limit"
420
+ expect(messages(logger.getRecentLogs(0))).toEqual(["one", "two", "three"]);
421
+
422
+ // Mutating the snapshot does not affect the buffer.
423
+ const snapshot: Array<RecentLogEntry> = logger.getRecentLogs();
424
+ snapshot.length = 0;
425
+ expect(logger.getRecentLogs().length).toBe(3);
426
+ });
427
+
428
+ test("the buffer trims back to the newest 1000 entries once it exceeds 1256", () => {
429
+ for (let i: number = 0; i < 1256; i++) {
430
+ logger.info(`m${i}`);
431
+ }
432
+
433
+ // At the soft ceiling nothing has been trimmed yet.
434
+ expect(logger.getRecentLogs().length).toBe(1256);
435
+ expect(logger.getRecentLogs()[0]!.message).toBe("m0");
436
+
437
+ logger.info("m1256");
438
+
439
+ const entries: Array<RecentLogEntry> = logger.getRecentLogs();
440
+ expect(entries.length).toBe(1000);
441
+ expect(entries[0]!.message).toBe("m257");
442
+ expect(entries[entries.length - 1]!.message).toBe("m1256");
443
+ });
444
+ });
445
+
446
+ describe("Logger telemetry export", () => {
447
+ beforeEach((): void => {
448
+ setLevel(ConfigLogLevel.INFO);
449
+ });
450
+
451
+ test("no attributes key is exported when there are no attributes", () => {
452
+ logger.info("bare");
453
+
454
+ expect(Object.keys(lastEmitted())).toEqual(["body", "severityNumber"]);
455
+ });
456
+
457
+ test("undefined attribute values are dropped and an all-undefined set exports no attributes", () => {
458
+ logger.info("empty attrs", { userId: undefined, projectId: undefined });
459
+
460
+ expect(lastEmitted().attributes).toBeUndefined();
461
+ });
462
+
463
+ test("attributes pass through, sensitive keys are redacted, strings are scrubbed, numbers and booleans kept", () => {
464
+ const attributes: LogAttributes = {
465
+ projectId: "project-1",
466
+ retryCount: 3,
467
+ isRetry: true,
468
+ apiKey: "abc123-plain-key",
469
+ note: "connecting with password=hunter2-super-secret",
470
+ skipped: undefined,
471
+ };
472
+
473
+ logger.info("with attrs", attributes);
474
+
475
+ const exported: Record<string, string | number | boolean> =
476
+ lastEmitted().attributes!;
477
+
478
+ expect(exported["projectId"]).toBe("project-1");
479
+ expect(exported["retryCount"]).toBe(3);
480
+ expect(exported["isRetry"]).toBe(true);
481
+ expect(exported["apiKey"]).toBe(REDACTED);
482
+ expect(String(exported["note"])).not.toContain("hunter2-super-secret");
483
+ expect(String(exported["note"])).toContain("connecting with");
484
+ expect(Object.keys(exported)).not.toContain("skipped");
485
+ });
486
+
487
+ test("ambient TelemetryContext attributes are merged and explicit attributes win", () => {
488
+ TelemetryContext.runWithContext(
489
+ { projectId: "ambient-project", monitorId: "monitor-9" },
490
+ (): void => {
491
+ logger.info("in context", { projectId: "explicit-project" });
492
+ },
493
+ );
494
+
495
+ const exported: Record<string, string | number | boolean> | undefined =
496
+ lastEmitted().attributes;
497
+
498
+ expect(exported?.["projectId"]).toBe("explicit-project");
499
+ expect(exported?.["monitorId"]).toBe("monitor-9");
500
+ });
501
+
502
+ test("error() always exports an error.class attribute alongside caller attributes", () => {
503
+ logger.error(new Error("unexpected"), { requestId: "req-1" });
504
+
505
+ const exported: Record<string, string | number | boolean> | undefined =
506
+ lastEmitted().attributes;
507
+
508
+ expect(exported?.["requestId"]).toBe("req-1");
509
+ expect(typeof exported?.["error.class"]).toBe("string");
510
+ });
511
+
512
+ test("emit() redacts the body and forwards the given severity without touching console or the buffer", () => {
513
+ logger.emit({
514
+ body: { token: "very-secret-token-value", ok: true },
515
+ severityNumber: SeverityNumber.FATAL,
516
+ attributes: { requestId: "r-7" },
517
+ });
518
+
519
+ const record: EmittedRecord = lastEmitted();
520
+ expect(record.severityNumber).toBe(SeverityNumber.FATAL);
521
+ expect(record.body).not.toContain("very-secret-token-value");
522
+ expect(record.body).toContain('"ok":true');
523
+ expect(record.attributes).toEqual({ requestId: "r-7" });
524
+ expect(totalConsoleCalls()).toBe(0);
525
+ expect(logger.getRecentLogs()).toEqual([]);
526
+ });
527
+
528
+ test("a null telemetry logger still logs to console and the buffer", () => {
529
+ mockTelemetryState.mode = "null";
530
+
531
+ logger.info("offline");
532
+
533
+ expect(consoleSpies.info.mock.calls).toEqual([["offline"]]);
534
+ expect(logger.getRecentLogs().length).toBe(1);
535
+ expect(mockTelemetryState.emitted).toEqual([]);
536
+ });
537
+
538
+ test("a throwing telemetry logger never propagates out of a log call", () => {
539
+ mockTelemetryState.mode = "throw";
540
+
541
+ expect((): void => {
542
+ logger.info("still fine");
543
+ logger.error("still fine");
544
+ logger.emit({ body: "x", severityNumber: SeverityNumber.INFO });
545
+ }).not.toThrow();
546
+
547
+ expect(consoleSpies.info.mock.calls).toEqual([["still fine"]]);
548
+ expect(logger.getRecentLogs().length).toBe(2);
549
+ });
550
+ });
551
+
552
+ describe("getLogAttributesFromRequest", () => {
553
+ test("returns an empty object for a missing request", () => {
554
+ expect(getLogAttributesFromRequest(undefined)).toEqual({});
555
+ expect(getLogAttributesFromRequest(null)).toEqual({});
556
+ });
557
+
558
+ test("extracts requestId, projectId and userId when present", () => {
559
+ expect(
560
+ getLogAttributesFromRequest({
561
+ requestId: "req-42",
562
+ tenantId: {
563
+ toString: (): string => {
564
+ return "tenant-1";
565
+ },
566
+ },
567
+ userAuthorization: {
568
+ userId: {
569
+ toString: (): string => {
570
+ return "user-9";
571
+ },
572
+ },
573
+ },
574
+ }),
575
+ ).toEqual({ requestId: "req-42", projectId: "tenant-1", userId: "user-9" });
576
+ });
577
+
578
+ test("omits fields that are absent", () => {
579
+ expect(getLogAttributesFromRequest({ requestId: "only" })).toEqual({
580
+ requestId: "only",
581
+ });
582
+ expect(getLogAttributesFromRequest({ userAuthorization: {} })).toEqual({});
583
+ });
584
+
585
+ test("returns an empty object when reading the request throws", () => {
586
+ expect(
587
+ getLogAttributesFromRequest({
588
+ requestId: "req-1",
589
+ tenantId: {
590
+ toString: (): string => {
591
+ throw new Error("bad id");
592
+ },
593
+ },
594
+ }),
595
+ ).toEqual({});
596
+ });
597
+ });