Skip to content

Commit 4f21649

Browse files
committed
fix: Update logger to serialize values properly
1 parent efda95a commit 4f21649

2 files changed

Lines changed: 185 additions & 2 deletions

File tree

app/src/services/logger.spec.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
2+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { Logger } from './logger';
5+
6+
describe('Logger', () => {
7+
let logger: Logger;
8+
9+
beforeEach(() => {
10+
logger = new Logger();
11+
});
12+
13+
describe('store and retrieval', () => {
14+
it('stores log entries with correct level and message', () => {
15+
logger.log('hello');
16+
logger.info('world');
17+
const logs = logger.getLogs();
18+
expect(logs).toHaveLength(2);
19+
expect(logs[0]).toMatchObject({ level: 'log', message: 'hello' });
20+
expect(logs[1]).toMatchObject({ level: 'info', message: 'world' });
21+
});
22+
23+
it('stores timestamps', () => {
24+
const before = new Date();
25+
logger.log('test');
26+
const after = new Date();
27+
const [entry] = logger.getLogs();
28+
expect(entry!.timestamp.getTime()).toBeGreaterThanOrEqual(
29+
before.getTime(),
30+
);
31+
expect(entry!.timestamp.getTime()).toBeLessThanOrEqual(after.getTime());
32+
});
33+
34+
it('getLogs returns a copy, not the internal array', () => {
35+
logger.log('test');
36+
const logs = logger.getLogs();
37+
logs.push({ level: 'fake', message: 'injected', timestamp: new Date() });
38+
expect(logger.getLogs()).toHaveLength(1);
39+
});
40+
41+
it('respects maxLogs by evicting the oldest entry', () => {
42+
for (let i = 0; i < 101; i++) {
43+
logger.log(`message ${i}`);
44+
}
45+
const logs = logger.getLogs();
46+
expect(logs).toHaveLength(100);
47+
expect(logs[0]!.message).toBe('message 1');
48+
expect(logs[99]!.message).toBe('message 100');
49+
});
50+
});
51+
52+
describe('clearLogs', () => {
53+
it('empties the log store', () => {
54+
logger.log('a');
55+
logger.log('b');
56+
logger.clearLogs();
57+
expect(logger.getLogs()).toHaveLength(0);
58+
});
59+
});
60+
61+
describe('error serialization', () => {
62+
it('serializes Error options in getLogs', () => {
63+
const err = new Error('something went wrong');
64+
logger.error('oops', err);
65+
const [entry] = logger.getLogs();
66+
const options = entry!.options as {
67+
name: string;
68+
message: string;
69+
stack: string;
70+
};
71+
expect(options.name).toBe('Error');
72+
expect(options.message).toBe('something went wrong');
73+
expect(options.stack).toContain('something went wrong');
74+
});
75+
76+
it('serializes Error options in getLogsAsString', () => {
77+
logger.error('oops', new Error('something went wrong'));
78+
const output = JSON.parse(logger.getLogsAsString());
79+
expect(output.options.message).toBe('something went wrong');
80+
expect(output.options.name).toBe('Error');
81+
expect(output.options.stack).toBeTruthy();
82+
});
83+
84+
it('serializes nested errors in getLogsAsString', () => {
85+
logger.error('oops', { cause: new Error('root cause') });
86+
const output = JSON.parse(logger.getLogsAsString());
87+
expect(output.options.cause.message).toBe('root cause');
88+
});
89+
90+
it('serializes custom Error subclass properties', () => {
91+
class HttpError extends Error {
92+
constructor(
93+
public readonly statusCode: number,
94+
message: string,
95+
) {
96+
super(message);
97+
this.name = 'HttpError';
98+
}
99+
}
100+
logger.error('request failed', new HttpError(404, 'not found'));
101+
const [entry] = logger.getLogs();
102+
const options = entry!.options as {
103+
name: string;
104+
message: string;
105+
statusCode: number;
106+
};
107+
expect(options.name).toBe('HttpError');
108+
expect(options.message).toBe('not found');
109+
expect(options.statusCode).toBe(404);
110+
});
111+
});
112+
113+
describe('getLogsAsString', () => {
114+
it('returns one JSON line per entry', () => {
115+
logger.log('first');
116+
logger.log('second');
117+
const lines = logger.getLogsAsString().split('\n');
118+
expect(lines).toHaveLength(2);
119+
expect(JSON.parse(lines[0]!)).toMatchObject({
120+
level: 'log',
121+
message: 'first',
122+
});
123+
expect(JSON.parse(lines[1]!)).toMatchObject({
124+
level: 'log',
125+
message: 'second',
126+
});
127+
});
128+
129+
it('returns empty string when no logs', () => {
130+
expect(logger.getLogsAsString()).toBe('');
131+
});
132+
});
133+
134+
describe('time', () => {
135+
it('logs completion time for sync actions', () => {
136+
logger.time('sync-task', () => {});
137+
const [entry] = logger.getLogs();
138+
expect(entry!.level).toBe('info');
139+
expect(entry!.message).toMatch(/^sync-task completed in \d+\.\d{2}ms$/);
140+
});
141+
142+
it('logs completion time for async actions', async () => {
143+
await logger.time('async-task', () => Promise.resolve());
144+
const [entry] = logger.getLogs();
145+
expect(entry!.level).toBe('info');
146+
expect(entry!.message).toMatch(/^async-task completed in \d+\.\d{2}ms$/);
147+
});
148+
149+
it('returns the resolved value for async actions', async () => {
150+
const result = await logger.time('task', () => Promise.resolve(42));
151+
expect(result).toBe(42);
152+
});
153+
});
154+
});

app/src/services/logger.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,47 @@ export class Logger {
77
timestamp: Date;
88
}[] = [];
99

10+
private serializeValue(value: unknown): unknown {
11+
if (value instanceof Error) {
12+
return {
13+
...value,
14+
name: value.name,
15+
message: value.message,
16+
stack: value.stack,
17+
};
18+
}
19+
return value;
20+
}
21+
1022
private store(level: string, message: string, options?: unknown): void {
11-
this.logs.push({ level, message, options, timestamp: new Date() });
23+
this.logs.push({
24+
level,
25+
message,
26+
options: this.serializeValue(options),
27+
timestamp: new Date(),
28+
});
1229
if (this.logs.length > this.maxLogs) {
1330
this.logs.shift();
1431
}
1532
}
1633

1734
getLogsAsString(): string {
35+
const replacer = (_key: string, value: unknown): unknown => {
36+
if (value instanceof Error) {
37+
return {
38+
...value,
39+
name: value.name,
40+
message: value.message,
41+
stack: value.stack,
42+
};
43+
}
44+
return value;
45+
};
46+
1847
return this.logs
1948
.map((entry) => {
2049
try {
21-
return JSON.stringify(entry);
50+
return JSON.stringify(entry, replacer);
2251
} catch {
2352
try {
2453
return JSON.stringify({

0 commit comments

Comments
 (0)