Skip to content

Commit 6968ca8

Browse files
authored
feat(logger): Add log buffer and flush method (#3617)
1 parent cbef310 commit 6968ca8

File tree

3 files changed

+338
-112
lines changed

3 files changed

+338
-112
lines changed

Diff for: packages/logger/src/Logger.ts

+120-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { LogJsonIndent, LogLevelThreshold, ReservedKeys } from './constants.js';
1313
import type { LogFormatter } from './formatter/LogFormatter.js';
1414
import type { LogItem } from './formatter/LogItem.js';
1515
import { PowertoolsLogFormatter } from './formatter/PowertoolsLogFormatter.js';
16+
import { CircularMap } from './logBuffer.js';
1617
import type { ConfigServiceInterface } from './types/ConfigServiceInterface.js';
1718
import type {
1819
ConstructorOptions,
@@ -142,7 +143,10 @@ class Logger extends Utility implements LoggerInterface {
142143
* Sometimes we need to log warnings before the logger is fully initialized, however we can't log them
143144
* immediately because the logger is not ready yet. This buffer stores those logs until the logger is ready.
144145
*/
145-
#buffer: [number, Parameters<Logger['createAndPopulateLogItem']>][] = [];
146+
readonly #initBuffer: [
147+
number,
148+
Parameters<Logger['createAndPopulateLogItem']>,
149+
][] = [];
146150
/**
147151
* Flag used to determine if the logger is initialized.
148152
*/
@@ -165,6 +169,29 @@ class Logger extends Utility implements LoggerInterface {
165169
*/
166170
#jsonReplacerFn?: CustomJsonReplacerFn;
167171

172+
/**
173+
* Represents whether the buffering functionality is enabled in the logger
174+
*/
175+
protected isBufferEnabled = false;
176+
177+
/**
178+
* Log level threshold for the buffer
179+
* Logs with a level lower than this threshold will be buffered
180+
*/
181+
protected bufferLogThreshold: number = LogLevelThreshold.DEBUG;
182+
/**
183+
* Max size of the buffer. Additions to the buffer beyond this size will
184+
* cause older logs to be evicted from the buffer
185+
*/
186+
readonly #maxBufferBytesSize = 1024;
187+
188+
/**
189+
* Contains buffered logs, grouped by _X_AMZN_TRACE_ID, each group with a max size of `maxBufferBytesSize`
190+
*/
191+
readonly #buffer: CircularMap<string> = new CircularMap({
192+
maxBytesSize: this.#maxBufferBytesSize,
193+
});
194+
168195
/**
169196
* Log level used by the current instance of Logger.
170197
*
@@ -182,11 +209,11 @@ class Logger extends Utility implements LoggerInterface {
182209
// all logs are buffered until the logger is initialized
183210
this.setOptions(rest);
184211
this.#isInitialized = true;
185-
for (const [level, log] of this.#buffer) {
212+
for (const [level, log] of this.#initBuffer) {
186213
// we call the method directly and create the log item just in time
187214
this.printLog(level, this.createAndPopulateLogItem(...log));
188215
}
189-
this.#buffer = [];
216+
this.#initBuffer = [];
190217
}
191218

192219
/**
@@ -919,7 +946,7 @@ class Logger extends Utility implements LoggerInterface {
919946
}
920947

921948
/**
922-
* Print a given log with given log level.
949+
* Print or buffer a given log with given log level.
923950
*
924951
* @param logLevel - The log level threshold
925952
* @param input - The log message
@@ -937,7 +964,33 @@ class Logger extends Utility implements LoggerInterface {
937964
this.createAndPopulateLogItem(logLevel, input, extraInput)
938965
);
939966
} else {
940-
this.#buffer.push([logLevel, [logLevel, input, extraInput]]);
967+
this.#initBuffer.push([logLevel, [logLevel, input, extraInput]]);
968+
}
969+
return;
970+
}
971+
972+
const traceId = this.envVarsService.getXrayTraceId();
973+
if (traceId !== undefined && this.shouldBufferLog(traceId, logLevel)) {
974+
try {
975+
this.bufferLogItem(
976+
traceId,
977+
this.createAndPopulateLogItem(logLevel, input, extraInput),
978+
logLevel
979+
);
980+
} catch (error) {
981+
this.printLog(
982+
LogLevelThreshold.WARN,
983+
this.createAndPopulateLogItem(
984+
LogLevelThreshold.WARN,
985+
`Unable to buffer log: ${(error as Error).message}`,
986+
[error as Error]
987+
)
988+
);
989+
990+
this.printLog(
991+
logLevel,
992+
this.createAndPopulateLogItem(logLevel, input, extraInput)
993+
);
941994
}
942995
}
943996
}
@@ -1169,6 +1222,68 @@ class Logger extends Utility implements LoggerInterface {
11691222
});
11701223
persistentKeys && this.appendPersistentKeys(persistentKeys);
11711224
}
1225+
1226+
/**
1227+
* Add a log to the buffer
1228+
* @param xrayTraceId - _X_AMZN_TRACE_ID of the request
1229+
* @param log - Log to be buffered
1230+
* @param logLevel - level of log to be buffered
1231+
*/
1232+
protected bufferLogItem(
1233+
xrayTraceId: string,
1234+
log: LogItem,
1235+
logLevel: number
1236+
): void {
1237+
log.prepareForPrint();
1238+
1239+
const stringified = JSON.stringify(
1240+
log.getAttributes(),
1241+
this.getJsonReplacer(),
1242+
this.logIndentation
1243+
);
1244+
1245+
this.#buffer.setItem(xrayTraceId, stringified, logLevel);
1246+
}
1247+
1248+
/**
1249+
* Flushes all items of the respective _X_AMZN_TRACE_ID within
1250+
* the buffer.
1251+
*/
1252+
protected flushBuffer(): void {
1253+
const traceId = this.envVarsService.getXrayTraceId();
1254+
if (traceId === undefined) {
1255+
return;
1256+
}
1257+
1258+
const buffer = this.#buffer.get(traceId) || [];
1259+
1260+
for (const item of buffer) {
1261+
const consoleMethod =
1262+
item.logLevel === LogLevelThreshold.CRITICAL
1263+
? 'error'
1264+
: (this.getLogLevelNameFromNumber(
1265+
item.logLevel
1266+
).toLowerCase() as keyof Omit<LogFunction, 'critical'>);
1267+
this.console[consoleMethod](item.value);
1268+
}
1269+
1270+
this.#buffer.delete(traceId);
1271+
}
1272+
/**
1273+
* Tests if the log meets the criteria to be buffered
1274+
* @param traceId - _X_AMZN_TRACE_ID of the request
1275+
* @param logLevel - The level of the log being considered
1276+
*/
1277+
protected shouldBufferLog(
1278+
traceId: string | undefined,
1279+
logLevel: number
1280+
): boolean {
1281+
return (
1282+
this.isBufferEnabled &&
1283+
traceId !== undefined &&
1284+
logLevel <= this.bufferLogThreshold
1285+
);
1286+
}
11721287
}
11731288

11741289
export { Logger };

Diff for: packages/logger/tests/unit/logBuffer.test.ts

+71-107
Original file line numberDiff line numberDiff line change
@@ -1,146 +1,110 @@
1-
import { describe, expect, it, vi } from 'vitest';
2-
import { CircularMap, SizedItem, SizedSet } from '../../src/logBuffer.js';
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { Logger } from '../../src/Logger.js';
3+
import { LogLevelThreshold } from '../../src/constants.js';
4+
5+
class TestLogger extends Logger {
6+
public enableBuffering() {
7+
this.isBufferEnabled = true;
8+
}
9+
public disableBuffering() {
10+
this.isBufferEnabled = false;
11+
}
12+
13+
public flushBufferWrapper(): void {
14+
this.flushBuffer();
15+
}
16+
17+
public overrideBufferLogItem(): void {
18+
this.bufferLogItem = vi.fn().mockImplementation(() => {
19+
throw new Error('bufferLogItem error');
20+
});
21+
}
22+
23+
public setbufferLevelThreshold(level: number): void {
24+
this.bufferLogThreshold = level;
25+
}
26+
}
327

4-
describe('SizedItem', () => {
5-
it('calculates the byteSize based on string value', () => {
28+
describe('bufferLog', () => {
29+
it('outputs a warning when there is an error buffering the log', () => {
630
// Prepare
7-
const logEntry = 'hello world';
31+
process.env.POWERTOOLS_DEV = 'true';
32+
const logger = new TestLogger();
33+
logger.enableBuffering();
34+
logger.overrideBufferLogItem();
835

936
// Act
10-
const item = new SizedItem(logEntry, 1);
37+
logger.debug('This is a debug');
1138

1239
// Assess
13-
const expectedByteSize = Buffer.byteLength(logEntry);
14-
expect(item.byteSize).toBe(expectedByteSize);
15-
});
16-
17-
it('throws an error if value is not a string', () => {
18-
// Prepare
19-
const invalidValue = { message: 'not a string' };
20-
21-
// Act & Assess
22-
expect(
23-
() => new SizedItem(invalidValue as unknown as string, 1)
24-
).toThrowError('Value should be a string');
40+
expect(console.debug).toBeCalledTimes(1);
41+
expect(console.warn).toBeCalledTimes(1);
2542
});
2643
});
2744

28-
describe('SizedSet', () => {
29-
it('adds an item and updates currentBytesSize correctly', () => {
30-
// Prepare
31-
const set = new SizedSet<string>();
32-
const item = new SizedItem('value', 1);
33-
34-
// Act
35-
set.add(item);
45+
describe('flushBuffer', () => {
46+
const ENVIRONMENT_VARIABLES = process.env;
3647

37-
// Assess
38-
expect(set.currentBytesSize).toBe(item.byteSize);
39-
expect(set.has(item)).toBe(true);
48+
beforeEach(() => {
49+
process.env = {
50+
...ENVIRONMENT_VARIABLES,
51+
POWERTOOLS_LOGGER_LOG_EVENT: 'true',
52+
POWERTOOLS_DEV: 'true',
53+
};
54+
vi.clearAllMocks();
4055
});
4156

42-
it('deletes an item and updates currentBytesSize correctly', () => {
57+
it('outputs buffered logs', () => {
4358
// Prepare
44-
const set = new SizedSet<string>();
45-
const item = new SizedItem('value', 1);
46-
set.add(item);
47-
const initialSize = set.currentBytesSize;
59+
const logger = new TestLogger({ logLevel: 'SILENT' });
60+
logger.enableBuffering();
61+
logger.setbufferLevelThreshold(LogLevelThreshold.CRITICAL);
4862

4963
// Act
50-
const result = set.delete(item);
64+
logger.debug('This is a debug');
65+
logger.warn('This is a warning');
66+
logger.critical('this is a critical');
5167

5268
// Assess
53-
expect(result).toBe(true);
54-
expect(set.currentBytesSize).toBe(initialSize - item.byteSize);
55-
expect(set.has(item)).toBe(false);
56-
});
57-
58-
it('clears all items and resets currentBytesSize to 0', () => {
59-
// Prepare
60-
const set = new SizedSet<string>();
61-
set.add(new SizedItem('b', 1));
62-
set.add(new SizedItem('d', 1));
69+
expect(console.warn).toHaveBeenCalledTimes(0);
70+
expect(console.error).toHaveBeenCalledTimes(0);
6371

6472
// Act
65-
set.clear();
73+
logger.flushBufferWrapper();
6674

6775
// Assess
68-
expect(set.currentBytesSize).toBe(0);
69-
expect(set.size).toBe(0);
76+
expect(console.warn).toHaveBeenCalledTimes(1);
77+
expect(console.error).toHaveBeenCalledTimes(1);
7078
});
7179

72-
it('removes the first inserted item with shift', () => {
80+
it('handles an empty buffer', () => {
7381
// Prepare
74-
const set = new SizedSet<string>();
75-
const item1 = new SizedItem('first', 1);
76-
const item2 = new SizedItem('second', 1);
77-
set.add(item1);
78-
set.add(item2);
82+
const logger = new TestLogger();
83+
logger.enableBuffering();
7984

8085
// Act
81-
const shiftedItem = set.shift();
82-
83-
// Assess
84-
expect(shiftedItem).toEqual(item1);
85-
expect(set.has(item1)).toBe(false);
86-
expect(set.currentBytesSize).toBe(item2.byteSize);
86+
logger.flushBufferWrapper();
8787
});
88-
});
8988

90-
describe('CircularMap', () => {
91-
it('adds items to a new buffer for a given key', () => {
89+
it('does not output buffered logs when trace id is not set', () => {
9290
// Prepare
93-
const maxBytes = 200;
94-
const circularMap = new CircularMap<string>({
95-
maxBytesSize: maxBytes,
96-
});
91+
process.env._X_AMZN_TRACE_ID = undefined;
92+
const logger = new TestLogger({});
93+
logger.enableBuffering();
9794

9895
// Act
99-
circularMap.setItem('trace-1', 'first log', 1);
96+
logger.debug('This is a debug');
97+
logger.warn('this is a warning');
10098

10199
// Assess
102-
const buffer = circularMap.get('trace-1');
103-
expect(buffer).toBeDefined();
104-
if (buffer) {
105-
expect(buffer.currentBytesSize).toBeGreaterThan(0);
106-
expect(buffer.size).toBe(1);
107-
}
108-
});
109-
110-
it('throws an error when an item exceeds maxBytesSize', () => {
111-
// Prepare
112-
const maxBytes = 10;
113-
const circularMap = new CircularMap<string>({
114-
maxBytesSize: maxBytes,
115-
});
116-
117-
// Act & Assess
118-
expect(() => {
119-
circularMap.setItem('trace-1', 'a very long message', 1);
120-
}).toThrowError('Item too big');
121-
});
122-
123-
it('evicts items when the buffer overflows and call the overflow callback', () => {
124-
// Prepare
125-
const options = {
126-
maxBytesSize: 15,
127-
onBufferOverflow: vi.fn(),
128-
};
129-
const circularMap = new CircularMap<string>(options);
130-
const smallEntry = '12345';
131-
132-
const entryByteSize = Buffer.byteLength(smallEntry);
133-
const entriesCount = Math.ceil(options.maxBytesSize / entryByteSize);
100+
expect(console.debug).toHaveBeenCalledTimes(0);
101+
expect(console.warn).toHaveBeenCalledTimes(1);
134102

135103
// Act
136-
for (let i = 0; i < entriesCount; i++) {
137-
circularMap.setItem('trace-1', smallEntry, 1);
138-
}
104+
logger.flushBufferWrapper();
139105

140106
// Assess
141-
expect(options.onBufferOverflow).toHaveBeenCalledTimes(1);
142-
expect(circularMap.get('trace-1')?.currentBytesSize).toBeLessThan(
143-
options.maxBytesSize
144-
);
107+
expect(console.debug).toHaveBeenCalledTimes(0);
108+
expect(console.warn).toHaveBeenCalledTimes(1);
145109
});
146110
});

0 commit comments

Comments
 (0)