Skip to content

Commit 618cdee

Browse files
feat(logger): add circular buffer (#3593)
Co-authored-by: Andrea Amorosi <[email protected]>
1 parent 50451b3 commit 618cdee

File tree

2 files changed

+240
-0
lines changed

2 files changed

+240
-0
lines changed

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

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { isString } from '@aws-lambda-powertools/commons/typeutils';
2+
3+
export class SizedItem<V> {
4+
public value: V;
5+
public logLevel: number;
6+
public byteSize: number;
7+
8+
constructor(value: V, logLevel: number) {
9+
if (!isString(value)) {
10+
throw new Error('Value should be a string');
11+
}
12+
this.value = value;
13+
this.logLevel = logLevel;
14+
this.byteSize = Buffer.byteLength(value as unknown as string);
15+
}
16+
}
17+
18+
export class SizedSet<V> extends Set<SizedItem<V>> {
19+
public currentBytesSize = 0;
20+
21+
add(item: SizedItem<V>): this {
22+
this.currentBytesSize += item.byteSize;
23+
super.add(item);
24+
return this;
25+
}
26+
27+
delete(item: SizedItem<V>): boolean {
28+
const wasDeleted = super.delete(item);
29+
if (wasDeleted) {
30+
this.currentBytesSize -= item.byteSize;
31+
}
32+
return wasDeleted;
33+
}
34+
35+
clear(): void {
36+
super.clear();
37+
this.currentBytesSize = 0;
38+
}
39+
40+
shift(): SizedItem<V> | undefined {
41+
const firstElement = this.values().next().value;
42+
if (firstElement) {
43+
this.delete(firstElement);
44+
}
45+
return firstElement;
46+
}
47+
}
48+
49+
export class CircularMap<V> extends Map<string, SizedSet<V>> {
50+
readonly #maxBytesSize: number;
51+
readonly #onBufferOverflow?: () => void;
52+
53+
constructor({
54+
maxBytesSize,
55+
onBufferOverflow,
56+
}: {
57+
maxBytesSize: number;
58+
onBufferOverflow?: () => void;
59+
}) {
60+
super();
61+
this.#maxBytesSize = maxBytesSize;
62+
this.#onBufferOverflow = onBufferOverflow;
63+
}
64+
65+
setItem(key: string, value: V, logLevel: number): this {
66+
const item = new SizedItem<V>(value, logLevel);
67+
68+
if (item.byteSize > this.#maxBytesSize) {
69+
throw new Error('Item too big');
70+
}
71+
72+
const buffer = this.get(key) || new SizedSet<V>();
73+
74+
if (buffer.currentBytesSize + item.byteSize >= this.#maxBytesSize) {
75+
this.#deleteFromBufferUntilSizeIsLessThanMax(buffer, item);
76+
if (this.#onBufferOverflow) {
77+
this.#onBufferOverflow();
78+
}
79+
}
80+
81+
buffer.add(item);
82+
super.set(key, buffer);
83+
return this;
84+
}
85+
86+
readonly #deleteFromBufferUntilSizeIsLessThanMax = (
87+
buffer: SizedSet<V>,
88+
item: SizedItem<V>
89+
) => {
90+
while (buffer.currentBytesSize + item.byteSize >= this.#maxBytesSize) {
91+
buffer.shift();
92+
}
93+
};
94+
}

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

+146
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { CircularMap, SizedItem, SizedSet } from '../../src/logBuffer.js';
3+
4+
describe('SizedItem', () => {
5+
it('calculates the byteSize based on string value', () => {
6+
// Prepare
7+
const logEntry = 'hello world';
8+
9+
// Act
10+
const item = new SizedItem(logEntry, 1);
11+
12+
// 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');
25+
});
26+
});
27+
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);
36+
37+
// Assess
38+
expect(set.currentBytesSize).toBe(item.byteSize);
39+
expect(set.has(item)).toBe(true);
40+
});
41+
42+
it('deletes an item and updates currentBytesSize correctly', () => {
43+
// Prepare
44+
const set = new SizedSet<string>();
45+
const item = new SizedItem('value', 1);
46+
set.add(item);
47+
const initialSize = set.currentBytesSize;
48+
49+
// Act
50+
const result = set.delete(item);
51+
52+
// 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));
63+
64+
// Act
65+
set.clear();
66+
67+
// Assess
68+
expect(set.currentBytesSize).toBe(0);
69+
expect(set.size).toBe(0);
70+
});
71+
72+
it('removes the first inserted item with shift', () => {
73+
// 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);
79+
80+
// 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);
87+
});
88+
});
89+
90+
describe('CircularMap', () => {
91+
it('adds items to a new buffer for a given key', () => {
92+
// Prepare
93+
const maxBytes = 200;
94+
const circularMap = new CircularMap<string>({
95+
maxBytesSize: maxBytes,
96+
});
97+
98+
// Act
99+
circularMap.setItem('trace-1', 'first log', 1);
100+
101+
// 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);
134+
135+
// Act
136+
for (let i = 0; i < entriesCount; i++) {
137+
circularMap.setItem('trace-1', smallEntry, 1);
138+
}
139+
140+
// Assess
141+
expect(options.onBufferOverflow).toHaveBeenCalledTimes(1);
142+
expect(circularMap.get('trace-1')?.currentBytesSize).toBeLessThan(
143+
options.maxBytesSize
144+
);
145+
});
146+
});

0 commit comments

Comments
 (0)