@onebun/logger 0.1.0
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/package.json +39 -0
- package/src/formatter.ts +257 -0
- package/src/index.ts +7 -0
- package/src/logger.test.ts +588 -0
- package/src/logger.ts +308 -0
- package/src/transport.ts +37 -0
- package/src/types.ts +61 -0
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onebun/logger",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Logger package for OneBun framework",
|
|
5
|
+
"license": "LGPL-3.0",
|
|
6
|
+
"author": "RemRyahirev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/RemRyahirev/onebun.git",
|
|
10
|
+
"directory": "packages/logger"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/RemRyahirev/onebun/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/RemRyahirev/onebun/tree/master/packages/logger#readme",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org/"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"main": "src/index.ts",
|
|
25
|
+
"module": "src/index.ts",
|
|
26
|
+
"types": "src/index.ts",
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "bun test"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"effect": "^3.13.10"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"bun-types": "1.2.2"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": "1.2.2"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/formatter.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type LogEntry,
|
|
3
|
+
type LogFormatter,
|
|
4
|
+
LogLevel,
|
|
5
|
+
} from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Maximum recursion depth for object formatting
|
|
9
|
+
*/
|
|
10
|
+
const MAX_OBJECT_DEPTH = 3;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Standard width for level names alignment in console output
|
|
14
|
+
*/
|
|
15
|
+
const LEVEL_NAME_WIDTH = 7;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Number of characters to display from trace/span IDs for brevity
|
|
19
|
+
*/
|
|
20
|
+
const TRACE_ID_DISPLAY_LENGTH = 8;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Colors for console output
|
|
24
|
+
*/
|
|
25
|
+
const COLORS: Record<string, string> = {
|
|
26
|
+
[LogLevel.Trace]: '\x1b[90m', // Gray
|
|
27
|
+
[LogLevel.Debug]: '\x1b[36m', // Cyan
|
|
28
|
+
[LogLevel.Info]: '\x1b[32m', // Green
|
|
29
|
+
[LogLevel.Warning]: '\x1b[33m', // Yellow
|
|
30
|
+
[LogLevel.Error]: '\x1b[31m', // Red
|
|
31
|
+
[LogLevel.Fatal]: '\x1b[35m', // Magenta
|
|
32
|
+
RESET: '\x1b[0m',
|
|
33
|
+
DIM: '\x1b[2m',
|
|
34
|
+
BRIGHT: '\x1b[1m',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Format a value for pretty output
|
|
39
|
+
*/
|
|
40
|
+
function formatValue(value: unknown, depth = 0): string {
|
|
41
|
+
if (value === null) {
|
|
42
|
+
return '\x1b[90mnull\x1b[0m';
|
|
43
|
+
}
|
|
44
|
+
if (value === undefined) {
|
|
45
|
+
return '\x1b[90mundefined\x1b[0m';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (typeof value === 'string') {
|
|
49
|
+
return `\x1b[32m"${value}"\x1b[0m`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof value === 'number') {
|
|
53
|
+
return `\x1b[33m${value}\x1b[0m`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof value === 'boolean') {
|
|
57
|
+
return `\x1b[35m${value}\x1b[0m`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (value instanceof Date) {
|
|
61
|
+
return `\x1b[36m${value.toISOString()}\x1b[0m`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (value instanceof Error) {
|
|
65
|
+
return `\x1b[31m${value.name}: ${value.message}\x1b[0m`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
if (depth > MAX_OBJECT_DEPTH) {
|
|
70
|
+
return '\x1b[90m[Array]\x1b[0m';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const items = value.map((item) => formatValue(item, depth + 1));
|
|
74
|
+
if (items.length === 0) {
|
|
75
|
+
return '\x1b[90m[]\x1b[0m';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const indent = ' '.repeat(depth + 1);
|
|
79
|
+
const closeIndent = ' '.repeat(depth);
|
|
80
|
+
|
|
81
|
+
return `\x1b[90m[\x1b[0m\n${indent}${items.join(`,\n${indent}`)}\n${closeIndent}\x1b[90m]\x1b[0m`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof value === 'object') {
|
|
85
|
+
if (depth > MAX_OBJECT_DEPTH) {
|
|
86
|
+
return '\x1b[90m[Object]\x1b[0m';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
90
|
+
if (entries.length === 0) {
|
|
91
|
+
return '\x1b[90m{}\x1b[0m';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const indent = ' '.repeat(depth + 1);
|
|
95
|
+
const closeIndent = ' '.repeat(depth);
|
|
96
|
+
|
|
97
|
+
const formattedEntries = entries.map(([key, val]) => {
|
|
98
|
+
const formattedKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)
|
|
99
|
+
? `\x1b[34m${key}\x1b[0m`
|
|
100
|
+
: `\x1b[32m"${key}"\x1b[0m`;
|
|
101
|
+
|
|
102
|
+
return `${formattedKey}: ${formatValue(val, depth + 1)}`;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return `\x1b[90m{\x1b[0m\n${indent}${formattedEntries.join(`,\n${indent}`)}\n${closeIndent}\x1b[90m}\x1b[0m`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (typeof value === 'function') {
|
|
109
|
+
return `\x1b[36m[Function: ${value.name || 'anonymous'}]\x1b[0m`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return String(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pretty console formatter with colors
|
|
117
|
+
*/
|
|
118
|
+
export class PrettyFormatter implements LogFormatter {
|
|
119
|
+
format(entry: LogEntry): string {
|
|
120
|
+
const time = entry.timestamp.toISOString();
|
|
121
|
+
|
|
122
|
+
const level = this.getLevelName(entry.level).padEnd(LEVEL_NAME_WIDTH);
|
|
123
|
+
const color = COLORS[entry.level.toString()] || '';
|
|
124
|
+
const reset = COLORS.RESET;
|
|
125
|
+
|
|
126
|
+
// Add trace information if available
|
|
127
|
+
const traceInfo = entry.trace
|
|
128
|
+
? ` [trace:${entry.trace.traceId.slice(-TRACE_ID_DISPLAY_LENGTH)} span:${entry.trace.spanId.slice(-TRACE_ID_DISPLAY_LENGTH)}]`
|
|
129
|
+
: '';
|
|
130
|
+
|
|
131
|
+
// Extract className from context for main log line
|
|
132
|
+
let className = '';
|
|
133
|
+
let contextWithoutClassName: Record<string, unknown> = {};
|
|
134
|
+
|
|
135
|
+
if (entry.context) {
|
|
136
|
+
const { className: extractedClassName, ...restContext } = entry.context;
|
|
137
|
+
if (extractedClassName && typeof extractedClassName === 'string') {
|
|
138
|
+
className = ` [${extractedClassName}]`;
|
|
139
|
+
}
|
|
140
|
+
contextWithoutClassName = restContext;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let message = `${color}${time} [${level}]${reset}${traceInfo}${className} ${entry.message}`;
|
|
144
|
+
|
|
145
|
+
// Handle additional data first (this is the main new functionality)
|
|
146
|
+
if (contextWithoutClassName.__additionalData) {
|
|
147
|
+
const additionalData = contextWithoutClassName.__additionalData as unknown[];
|
|
148
|
+
if (additionalData.length > 0) {
|
|
149
|
+
const formattedData = additionalData.map((data) => formatValue(data)).join(' ');
|
|
150
|
+
message += ` ${formattedData}`;
|
|
151
|
+
}
|
|
152
|
+
// Remove __additionalData from context since we've processed it
|
|
153
|
+
delete contextWithoutClassName.__additionalData;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Handle regular context (excluding special fields and className)
|
|
157
|
+
if (Object.keys(contextWithoutClassName).length > 0) {
|
|
158
|
+
const contextWithoutSpecialFields = { ...contextWithoutClassName };
|
|
159
|
+
delete contextWithoutSpecialFields.SHOW_CONTEXT;
|
|
160
|
+
|
|
161
|
+
if (Object.keys(contextWithoutSpecialFields).length > 0) {
|
|
162
|
+
message += `\n${COLORS.DIM}Context:${COLORS.RESET} ${formatValue(contextWithoutSpecialFields)}`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (entry.error) {
|
|
167
|
+
message += `\n${COLORS[LogLevel.Error]}Error:${COLORS.RESET} ${entry.error.stack || entry.error.message}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return message;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private getLevelName(level: LogLevel): string {
|
|
174
|
+
switch (level) {
|
|
175
|
+
case LogLevel.Trace:
|
|
176
|
+
return 'TRACE';
|
|
177
|
+
case LogLevel.Debug:
|
|
178
|
+
return 'DEBUG';
|
|
179
|
+
case LogLevel.Info:
|
|
180
|
+
return 'INFO';
|
|
181
|
+
case LogLevel.Warning:
|
|
182
|
+
return 'WARN';
|
|
183
|
+
case LogLevel.Error:
|
|
184
|
+
return 'ERROR';
|
|
185
|
+
case LogLevel.Fatal:
|
|
186
|
+
return 'FATAL';
|
|
187
|
+
default:
|
|
188
|
+
return 'UNKNOWN';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* JSON formatter for structured logging
|
|
195
|
+
*/
|
|
196
|
+
export class JsonFormatter implements LogFormatter {
|
|
197
|
+
format(entry: LogEntry): string {
|
|
198
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
199
|
+
const logData: any = {
|
|
200
|
+
timestamp: entry.timestamp.toISOString(),
|
|
201
|
+
level: this.getLevelName(entry.level),
|
|
202
|
+
message: entry.message,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (entry.trace) {
|
|
206
|
+
logData.trace = {
|
|
207
|
+
traceId: entry.trace.traceId,
|
|
208
|
+
spanId: entry.trace.spanId,
|
|
209
|
+
...(entry.trace.parentSpanId ? { parentSpanId: entry.trace.parentSpanId } : {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (entry.context) {
|
|
214
|
+
const contextData = { ...entry.context };
|
|
215
|
+
|
|
216
|
+
// Extract additional data if present
|
|
217
|
+
if (contextData.__additionalData) {
|
|
218
|
+
logData.additionalData = contextData.__additionalData;
|
|
219
|
+
delete contextData.__additionalData;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Add remaining context
|
|
223
|
+
if (Object.keys(contextData).length > 0) {
|
|
224
|
+
logData.context = contextData;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (entry.error) {
|
|
229
|
+
logData.error = {
|
|
230
|
+
message: entry.error.message,
|
|
231
|
+
stack: entry.error.stack,
|
|
232
|
+
name: entry.error.name,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return JSON.stringify(logData);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private getLevelName(level: LogLevel): string {
|
|
240
|
+
switch (level) {
|
|
241
|
+
case LogLevel.Trace:
|
|
242
|
+
return 'trace';
|
|
243
|
+
case LogLevel.Debug:
|
|
244
|
+
return 'debug';
|
|
245
|
+
case LogLevel.Info:
|
|
246
|
+
return 'info';
|
|
247
|
+
case LogLevel.Warning:
|
|
248
|
+
return 'warn';
|
|
249
|
+
case LogLevel.Error:
|
|
250
|
+
return 'error';
|
|
251
|
+
case LogLevel.Fatal:
|
|
252
|
+
return 'fatal';
|
|
253
|
+
default:
|
|
254
|
+
return 'unknown';
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
it,
|
|
4
|
+
expect,
|
|
5
|
+
spyOn,
|
|
6
|
+
beforeEach,
|
|
7
|
+
afterEach,
|
|
8
|
+
} from 'bun:test';
|
|
9
|
+
import { Effect } from 'effect';
|
|
10
|
+
|
|
11
|
+
import { JsonFormatter, PrettyFormatter } from './formatter';
|
|
12
|
+
import {
|
|
13
|
+
LoggerService,
|
|
14
|
+
createSyncLogger,
|
|
15
|
+
makeDevLogger,
|
|
16
|
+
} from './logger';
|
|
17
|
+
import { makeLogger } from './logger';
|
|
18
|
+
import { ConsoleTransport } from './transport';
|
|
19
|
+
import { LogLevel, type LogEntry } from './types';
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
describe('PrettyFormatter', () => {
|
|
23
|
+
it('formats basic info with color, level and message', () => {
|
|
24
|
+
const formatter = new PrettyFormatter();
|
|
25
|
+
const entry: LogEntry = {
|
|
26
|
+
level: LogLevel.Info,
|
|
27
|
+
message: 'Hello',
|
|
28
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
29
|
+
};
|
|
30
|
+
const out = formatter.format(entry);
|
|
31
|
+
expect(out).toContain('[INFO ]');
|
|
32
|
+
expect(out).toContain('Hello');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('includes className in brackets and additionalData inline', () => {
|
|
36
|
+
const formatter = new PrettyFormatter();
|
|
37
|
+
const entry: LogEntry = {
|
|
38
|
+
level: LogLevel.Debug,
|
|
39
|
+
message: 'Data',
|
|
40
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
42
|
+
context: { className: 'Test', __additionalData: ['a', 1, true] },
|
|
43
|
+
};
|
|
44
|
+
const out = formatter.format(entry);
|
|
45
|
+
expect(out).toContain('[Test]');
|
|
46
|
+
expect(out).toContain('"a"');
|
|
47
|
+
expect(out).toContain('1');
|
|
48
|
+
expect(out).toContain('true');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('prints context object pretty when present (excluding special fields)', () => {
|
|
52
|
+
const formatter = new PrettyFormatter();
|
|
53
|
+
const entry: LogEntry = {
|
|
54
|
+
level: LogLevel.Warning,
|
|
55
|
+
message: 'Warn',
|
|
56
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
57
|
+
context: { a: 1, SHOW_CONTEXT: true },
|
|
58
|
+
};
|
|
59
|
+
const out = formatter.format(entry);
|
|
60
|
+
expect(out).toContain('Context:');
|
|
61
|
+
expect(out).toContain('a');
|
|
62
|
+
expect(out).not.toContain('SHOW_CONTEXT');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('includes error stack if error present', () => {
|
|
66
|
+
const formatter = new PrettyFormatter();
|
|
67
|
+
const error = new Error('Boom');
|
|
68
|
+
const entry: LogEntry = {
|
|
69
|
+
level: LogLevel.Error,
|
|
70
|
+
message: 'Err',
|
|
71
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
72
|
+
error,
|
|
73
|
+
};
|
|
74
|
+
const out = formatter.format(entry);
|
|
75
|
+
expect(out).toContain('Error:');
|
|
76
|
+
expect(out).toContain('Boom');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('adds trace info with last 8 chars', () => {
|
|
80
|
+
const formatter = new PrettyFormatter();
|
|
81
|
+
const entry: LogEntry = {
|
|
82
|
+
level: LogLevel.Info,
|
|
83
|
+
message: 'Traced',
|
|
84
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
85
|
+
trace: { traceId: '1234567890abcdef', spanId: 'abcdef1234567890' },
|
|
86
|
+
} as LogEntry;
|
|
87
|
+
const out = formatter.format(entry);
|
|
88
|
+
expect(out).toContain('trace:90abcdef');
|
|
89
|
+
expect(out).toContain('span:34567890');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('JsonFormatter', () => {
|
|
94
|
+
it('produces structured json', () => {
|
|
95
|
+
const formatter = new JsonFormatter();
|
|
96
|
+
const entry: LogEntry = {
|
|
97
|
+
level: LogLevel.Info,
|
|
98
|
+
message: 'Hi',
|
|
99
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
101
|
+
context: { x: 1, __additionalData: [2, 'b'] },
|
|
102
|
+
error: new Error('E'),
|
|
103
|
+
trace: { traceId: 't', spanId: 's', parentSpanId: 'p' },
|
|
104
|
+
};
|
|
105
|
+
const obj = JSON.parse(formatter.format(entry));
|
|
106
|
+
expect(obj.level).toBe('info');
|
|
107
|
+
expect(obj.message).toBe('Hi');
|
|
108
|
+
expect(obj.context).toEqual({ x: 1 });
|
|
109
|
+
expect(obj.additionalData).toEqual([2, 'b']);
|
|
110
|
+
expect(obj.error.message).toBe('E');
|
|
111
|
+
expect(obj.trace.traceId).toBe('t');
|
|
112
|
+
expect(obj.trace.parentSpanId).toBe('p');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('ConsoleTransport', () => {
|
|
117
|
+
const transport = new ConsoleTransport();
|
|
118
|
+
let logSpy: ReturnType<typeof spyOn>;
|
|
119
|
+
let infoSpy: ReturnType<typeof spyOn>;
|
|
120
|
+
let warnSpy: ReturnType<typeof spyOn>;
|
|
121
|
+
let errorSpy: ReturnType<typeof spyOn>;
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
logSpy = spyOn(console, 'log').mockImplementation(() => undefined);
|
|
125
|
+
infoSpy = spyOn(console, 'info').mockImplementation(() => undefined);
|
|
126
|
+
warnSpy = spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
127
|
+
errorSpy = spyOn(console, 'error').mockImplementation(() => undefined);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterEach(() => {
|
|
131
|
+
logSpy.mockRestore();
|
|
132
|
+
infoSpy.mockRestore();
|
|
133
|
+
warnSpy.mockRestore();
|
|
134
|
+
errorSpy.mockRestore();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const ts = new Date();
|
|
138
|
+
const base: Omit<LogEntry, 'level'|'message'> = { timestamp: ts };
|
|
139
|
+
|
|
140
|
+
it('routes info to console.info', () => {
|
|
141
|
+
Effect.runSync(transport.log('x', { ...base, level: LogLevel.Info, message: 'm' }));
|
|
142
|
+
expect(infoSpy).toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('routes warn to console.warn', () => {
|
|
146
|
+
Effect.runSync(transport.log('x', { ...base, level: LogLevel.Warning, message: 'm' }));
|
|
147
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('routes error/fatal to console.error and others to console.log', () => {
|
|
151
|
+
Effect.runSync(transport.log('x', { ...base, level: LogLevel.Error, message: 'm' }));
|
|
152
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
153
|
+
Effect.runSync(transport.log('x', { ...base, level: LogLevel.Fatal, message: 'm' }));
|
|
154
|
+
expect(errorSpy).toHaveBeenCalledTimes(2);
|
|
155
|
+
Effect.runSync(transport.log('x', { ...base, level: LogLevel.Debug, message: 'm' }));
|
|
156
|
+
expect(logSpy).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('routes trace to console.log', () => {
|
|
160
|
+
Effect.runSync(transport.log('trace message', { ...base, level: LogLevel.Trace, message: 'trace' }));
|
|
161
|
+
expect(logSpy).toHaveBeenCalledWith('trace message');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should create ConsoleTransport instance', () => {
|
|
165
|
+
const newTransport = new ConsoleTransport();
|
|
166
|
+
expect(newTransport).toBeInstanceOf(ConsoleTransport);
|
|
167
|
+
expect(typeof newTransport.log).toBe('function');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should create ConsoleTransport with implicit constructor', () => {
|
|
171
|
+
// Test explicit constructor call to improve coverage
|
|
172
|
+
const transport1 = new ConsoleTransport();
|
|
173
|
+
const transport2 = new ConsoleTransport();
|
|
174
|
+
|
|
175
|
+
expect(transport1).toBeInstanceOf(ConsoleTransport);
|
|
176
|
+
expect(transport2).toBeInstanceOf(ConsoleTransport);
|
|
177
|
+
expect(transport1).not.toBe(transport2); // Different instances
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should handle unknown log level', () => {
|
|
181
|
+
// Test the default case in switch statement
|
|
182
|
+
const unknownLevel = 999 as LogLevel;
|
|
183
|
+
Effect.runSync(transport.log('unknown level', { ...base, level: unknownLevel, message: 'test' }));
|
|
184
|
+
expect(logSpy).toHaveBeenCalledWith('unknown level');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('Logger + SyncLogger basic flow', () => {
|
|
189
|
+
it('filters by minLevel and merges contexts and additional data', () => {
|
|
190
|
+
// Create a minimal logger by constructing our own transport that captures output
|
|
191
|
+
const outputs: string[] = [];
|
|
192
|
+
const transport = new (class extends ConsoleTransport {
|
|
193
|
+
override log(formattedEntry: string, entry: LogEntry) {
|
|
194
|
+
return Effect.sync(() => {
|
|
195
|
+
outputs.push(formattedEntry + '|' + entry.level);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
})();
|
|
199
|
+
|
|
200
|
+
const loggerLayer = makeDevLogger({ minLevel: LogLevel.Info, transport });
|
|
201
|
+
|
|
202
|
+
// Extract the underlying logger by creating a small helper service instance
|
|
203
|
+
// We cannot easily read from Layer in tests, so we re-create SyncLogger through
|
|
204
|
+
// the public API using LoggerService tag and Effect runtime.
|
|
205
|
+
|
|
206
|
+
// Build a ad-hoc logger effect from the layer and run logging through Effect directly
|
|
207
|
+
const loggerEffect = Effect.flatMap(LoggerService, (logger) => Effect.succeed(logger));
|
|
208
|
+
const provided = Effect.provide(loggerEffect, loggerLayer);
|
|
209
|
+
const logger = Effect.runSync(provided);
|
|
210
|
+
|
|
211
|
+
// Create sync wrapper for easier calls
|
|
212
|
+
const sync = createSyncLogger(logger);
|
|
213
|
+
|
|
214
|
+
sync.debug('dropped'); // below Info, should be filtered
|
|
215
|
+
sync.info('hello', { a: 1 }, 'xtra');
|
|
216
|
+
|
|
217
|
+
expect(outputs.length).toBe(1);
|
|
218
|
+
expect(outputs[0]).toContain('hello');
|
|
219
|
+
expect(outputs[0]).toContain('Context');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('child adds context and uses global trace context in SyncLogger', () => {
|
|
223
|
+
const outputs: LogEntry[] = [];
|
|
224
|
+
const transport = new (class extends ConsoleTransport {
|
|
225
|
+
override log(_formattedEntry: string, entry: LogEntry) {
|
|
226
|
+
return Effect.sync(() => {
|
|
227
|
+
outputs.push(entry);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
|
|
232
|
+
const layer = makeDevLogger({ transport });
|
|
233
|
+
const logger = Effect.runSync(Effect.provide(Effect.flatMap(LoggerService, (l) => Effect.succeed(l)), layer));
|
|
234
|
+
const sync = createSyncLogger(logger);
|
|
235
|
+
|
|
236
|
+
// set global trace for SyncLogger path
|
|
237
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
238
|
+
(globalThis as any).__onebunCurrentTraceContext = { traceId: 't123456789', spanId: 's123456789' };
|
|
239
|
+
|
|
240
|
+
const child = sync.child({ className: 'Child' });
|
|
241
|
+
child.info('hi');
|
|
242
|
+
|
|
243
|
+
expect(outputs[0].context?.className).toBe('Child');
|
|
244
|
+
expect(outputs[0].trace?.traceId).toBe('t123456789');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
// Additional tests to improve coverage near 100%
|
|
250
|
+
|
|
251
|
+
describe('PrettyFormatter - additional value shapes', () => {
|
|
252
|
+
it('formats various value types in additionalData', () => {
|
|
253
|
+
const f = new PrettyFormatter();
|
|
254
|
+
const err = new Error('X');
|
|
255
|
+
const fn = function namedFn() {
|
|
256
|
+
return 1;
|
|
257
|
+
};
|
|
258
|
+
const entry: LogEntry = {
|
|
259
|
+
level: LogLevel.Debug,
|
|
260
|
+
message: 'v',
|
|
261
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
262
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
263
|
+
context: { __additionalData: [null, undefined, 123, 's', false, new Date('2025-01-01T00:00:00.000Z'), err, fn] },
|
|
264
|
+
};
|
|
265
|
+
const out = f.format(entry);
|
|
266
|
+
expect(out).toContain('null');
|
|
267
|
+
expect(out).toContain('undefined');
|
|
268
|
+
expect(out).toContain('123');
|
|
269
|
+
expect(out).toContain('"s"');
|
|
270
|
+
expect(out).toContain('false');
|
|
271
|
+
expect(out).toContain('2025-01-01T00:00:00.000Z');
|
|
272
|
+
expect(out).toContain('Error: X');
|
|
273
|
+
expect(out).toContain('[Function: namedFn]');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('pretty prints empty and deep structures with truncation', () => {
|
|
277
|
+
const f = new PrettyFormatter();
|
|
278
|
+
const deep = { a: { b: { c: { d: 1 } } } };
|
|
279
|
+
const entry: LogEntry = {
|
|
280
|
+
level: LogLevel.Info,
|
|
281
|
+
message: 'ctx',
|
|
282
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
283
|
+
context: { emptyObj: {}, emptyArr: [], deep },
|
|
284
|
+
};
|
|
285
|
+
const out = f.format(entry);
|
|
286
|
+
expect(out).toContain('emptyObj');
|
|
287
|
+
expect(out).toContain('{}');
|
|
288
|
+
expect(out).toContain('emptyArr');
|
|
289
|
+
expect(out).toContain('[]');
|
|
290
|
+
// при глубокой структуре встречается маркер [Object]
|
|
291
|
+
expect(out).toContain('[Object]');
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('JsonFormatter - variants', () => {
|
|
296
|
+
it('omits context and additionalData when absent; trace without parent', () => {
|
|
297
|
+
const f = new JsonFormatter();
|
|
298
|
+
const entry: LogEntry = {
|
|
299
|
+
level: LogLevel.Trace,
|
|
300
|
+
message: 'j',
|
|
301
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
302
|
+
trace: { traceId: 'tt', spanId: 'ss' },
|
|
303
|
+
};
|
|
304
|
+
const o = JSON.parse(f.format(entry));
|
|
305
|
+
expect(o.level).toBe('trace');
|
|
306
|
+
expect(o.context).toBeUndefined();
|
|
307
|
+
expect(o.additionalData).toBeUndefined();
|
|
308
|
+
expect(o.trace.traceId).toBe('tt');
|
|
309
|
+
expect(o.trace.parentSpanId).toBeUndefined();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('LoggerImpl methods and makeLogger env selection', () => {
|
|
314
|
+
it('calls all level methods at or above minLevel', () => {
|
|
315
|
+
const seen: LogEntry[] = [];
|
|
316
|
+
const transport = new (class extends ConsoleTransport {
|
|
317
|
+
override log(_formatted: string, e: LogEntry) {
|
|
318
|
+
return Effect.sync(() => {
|
|
319
|
+
seen.push(e);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
})();
|
|
323
|
+
const layer = makeDevLogger({ minLevel: LogLevel.Trace, transport });
|
|
324
|
+
const logger = Effect.runSync(Effect.provide(Effect.flatMap(LoggerService, (l) => Effect.succeed(l)), layer));
|
|
325
|
+
const sync = createSyncLogger(logger);
|
|
326
|
+
sync.trace('t');
|
|
327
|
+
sync.debug('d');
|
|
328
|
+
sync.info('i');
|
|
329
|
+
sync.warn('w');
|
|
330
|
+
sync.error('e', new Error('E1'));
|
|
331
|
+
sync.fatal('f', new Error('E2'));
|
|
332
|
+
expect(seen.map((e) => e.level)).toEqual([
|
|
333
|
+
LogLevel.Trace, LogLevel.Debug, LogLevel.Info, LogLevel.Warning, LogLevel.Error, LogLevel.Fatal,
|
|
334
|
+
]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('makeLogger picks prod logger when NODE_ENV=production', () => {
|
|
338
|
+
const prev = process.env.NODE_ENV;
|
|
339
|
+
process.env.NODE_ENV = 'production';
|
|
340
|
+
const seen: LogEntry[] = [];
|
|
341
|
+
const transport = new (class extends ConsoleTransport {
|
|
342
|
+
override log(_formatted: string, e: LogEntry) {
|
|
343
|
+
return Effect.sync(() => {
|
|
344
|
+
seen.push(e);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
})();
|
|
348
|
+
const layer = makeLogger({ transport });
|
|
349
|
+
const logger = Effect.runSync(Effect.provide(Effect.flatMap(LoggerService, (l) => Effect.succeed(l)), layer));
|
|
350
|
+
const sync = createSyncLogger(logger);
|
|
351
|
+
// Debug должен быть отфильтрован в prod (minLevel Info)
|
|
352
|
+
sync.debug('dbg');
|
|
353
|
+
sync.info('ok');
|
|
354
|
+
expect(seen.length).toBe(1);
|
|
355
|
+
expect(seen[0].level).toBe(LogLevel.Info);
|
|
356
|
+
process.env.NODE_ENV = prev;
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe('LoggerImpl trace context fallback to __onebunTraceService', () => {
|
|
361
|
+
it('uses global trace service when current trace context is absent', () => {
|
|
362
|
+
// очистить direct context
|
|
363
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
364
|
+
(globalThis as any).__onebunCurrentTraceContext = undefined;
|
|
365
|
+
const outputs: LogEntry[] = [];
|
|
366
|
+
const transport = new (class extends ConsoleTransport {
|
|
367
|
+
override log(_f: string, e: LogEntry) {
|
|
368
|
+
return Effect.sync(() => {
|
|
369
|
+
outputs.push(e);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
})();
|
|
373
|
+
// подготовить псевдо trace service
|
|
374
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
375
|
+
(globalThis as any).__onebunTraceService = {
|
|
376
|
+
getCurrentTraceContext: () => Effect.succeed({ traceId: 'svc-trace', spanId: 'svc-span' }),
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const layer = makeDevLogger({ transport });
|
|
380
|
+
const logger = Effect.runSync(Effect.provide(Effect.flatMap(LoggerService, (l) => Effect.succeed(l)), layer));
|
|
381
|
+
const sync = createSyncLogger(logger);
|
|
382
|
+
sync.info('ping');
|
|
383
|
+
|
|
384
|
+
expect(outputs[0].trace?.traceId).toBe('svc-trace');
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
// Added tests to raise function coverage for formatter.ts
|
|
390
|
+
|
|
391
|
+
describe('Formatter getLevelName coverage', () => {
|
|
392
|
+
it('JsonFormatter maps all levels to lowercase names', () => {
|
|
393
|
+
const f = new JsonFormatter();
|
|
394
|
+
const ts = new Date('2025-01-01T00:00:00.000Z');
|
|
395
|
+
const mk = (level: LogLevel) => JSON.parse(f.format({ level, message: 'm', timestamp: ts })).level as string;
|
|
396
|
+
expect(mk(LogLevel.Trace)).toBe('trace');
|
|
397
|
+
expect(mk(LogLevel.Debug)).toBe('debug');
|
|
398
|
+
expect(mk(LogLevel.Info)).toBe('info');
|
|
399
|
+
expect(mk(LogLevel.Warning)).toBe('warn');
|
|
400
|
+
expect(mk(LogLevel.Error)).toBe('error');
|
|
401
|
+
expect(mk(LogLevel.Fatal)).toBe('fatal');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('PrettyFormatter maps TRACE and FATAL to bracketed level names', () => {
|
|
405
|
+
const f = new PrettyFormatter();
|
|
406
|
+
const ts = new Date('2025-01-01T00:00:00.000Z');
|
|
407
|
+
const outTrace = f.format({ level: LogLevel.Trace, message: 't', timestamp: ts });
|
|
408
|
+
const outFatal = f.format({ level: LogLevel.Fatal, message: 'f', timestamp: ts });
|
|
409
|
+
expect(outTrace).toContain('[TRACE ]');
|
|
410
|
+
expect(outFatal).toContain('[FATAL ]');
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe('formatValue rare branches via PrettyFormatter additionalData', () => {
|
|
415
|
+
it('handles empty array and multi-element array with pretty multi-line', () => {
|
|
416
|
+
const f = new PrettyFormatter();
|
|
417
|
+
const ts = new Date('2025-01-01T00:00:00.000Z');
|
|
418
|
+
const outEmpty = f.format({
|
|
419
|
+
level: LogLevel.Debug,
|
|
420
|
+
message: 'empty',
|
|
421
|
+
timestamp: ts,
|
|
422
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
423
|
+
context: { __additionalData: [[]] },
|
|
424
|
+
});
|
|
425
|
+
expect(outEmpty).toContain('[]');
|
|
426
|
+
|
|
427
|
+
const outNonEmpty = f.format({
|
|
428
|
+
level: LogLevel.Debug,
|
|
429
|
+
message: 'nonempty',
|
|
430
|
+
timestamp: ts,
|
|
431
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
432
|
+
context: { __additionalData: [[1, 2, 3]] },
|
|
433
|
+
});
|
|
434
|
+
// Ожидаем многострочное форматирование массива (наличие символов [ и ] с переносами строк)
|
|
435
|
+
expect(outNonEmpty).toMatch(/\[\x1b\[0m\n/); // присутствует перевод строки после [
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('handles deep nested array producing [Array] marker when depth exceeded', () => {
|
|
439
|
+
const f = new PrettyFormatter();
|
|
440
|
+
const deepArr = [[[[[1]]]]]; // глубина 5 (> MAX_OBJECT_DEPTH)
|
|
441
|
+
const out = f.format({
|
|
442
|
+
level: LogLevel.Debug,
|
|
443
|
+
message: 'deep',
|
|
444
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
445
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
446
|
+
context: { __additionalData: [deepArr] },
|
|
447
|
+
});
|
|
448
|
+
expect(out).toContain('[Array]');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('formats object keys that require quotes', () => {
|
|
452
|
+
const f = new PrettyFormatter();
|
|
453
|
+
const out = f.format({
|
|
454
|
+
level: LogLevel.Info,
|
|
455
|
+
message: 'obj',
|
|
456
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
457
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
458
|
+
context: { 'a-b': 1 },
|
|
459
|
+
});
|
|
460
|
+
// Ключ с дефисом должен печататься в кавычках
|
|
461
|
+
expect(out).toContain('"a-b"');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('prints anonymous function with fallback name and covers String(value) for symbol and bigint', () => {
|
|
465
|
+
const f = new PrettyFormatter();
|
|
466
|
+
// анонимная функция
|
|
467
|
+
|
|
468
|
+
const anon = function () {
|
|
469
|
+
return 0;
|
|
470
|
+
};
|
|
471
|
+
const sym = Symbol('s');
|
|
472
|
+
const big = BigInt(42);
|
|
473
|
+
const out = f.format({
|
|
474
|
+
level: LogLevel.Debug,
|
|
475
|
+
message: 'misc',
|
|
476
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
477
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
478
|
+
context: { __additionalData: [anon, sym, big] },
|
|
479
|
+
});
|
|
480
|
+
expect(out).toContain('[Function:');
|
|
481
|
+
// символы и бигинты проходят в String(value)
|
|
482
|
+
expect(out).toContain('Symbol(s)');
|
|
483
|
+
expect(out).toContain('42');
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe('Formatter edge cases and additional coverage', () => {
|
|
488
|
+
it('PrettyFormatter should handle Set and Map objects', () => {
|
|
489
|
+
const formatter = new PrettyFormatter();
|
|
490
|
+
const set = new Set([1, 2, 3]);
|
|
491
|
+
const map = new Map([['key', 'value']]);
|
|
492
|
+
|
|
493
|
+
const out = formatter.format({
|
|
494
|
+
level: LogLevel.Info,
|
|
495
|
+
message: 'collections',
|
|
496
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
497
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
498
|
+
context: { __additionalData: [set, map] },
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Sets and Maps are formatted as empty objects {} in the formatter
|
|
502
|
+
expect(out).toContain('{}');
|
|
503
|
+
expect(out).toContain('collections');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('PrettyFormatter should handle Date objects', () => {
|
|
507
|
+
const formatter = new PrettyFormatter();
|
|
508
|
+
const date = new Date('2025-01-01T12:00:00.000Z');
|
|
509
|
+
|
|
510
|
+
const out = formatter.format({
|
|
511
|
+
level: LogLevel.Info,
|
|
512
|
+
message: 'dates',
|
|
513
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
514
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
515
|
+
context: { __additionalData: [date] },
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
expect(out).toContain('2025');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('PrettyFormatter should handle class instances', () => {
|
|
522
|
+
const formatter = new PrettyFormatter();
|
|
523
|
+
|
|
524
|
+
class TestClass {
|
|
525
|
+
prop = 'value';
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const instance = new TestClass();
|
|
529
|
+
|
|
530
|
+
const out = formatter.format({
|
|
531
|
+
level: LogLevel.Info,
|
|
532
|
+
message: 'instance',
|
|
533
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
534
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
535
|
+
context: { __additionalData: [instance] },
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Class instances show their properties, not class name
|
|
539
|
+
expect(out).toContain('prop');
|
|
540
|
+
expect(out).toContain('value');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('JsonFormatter should handle all log levels correctly', () => {
|
|
544
|
+
const formatter = new JsonFormatter();
|
|
545
|
+
const timestamp = new Date('2025-01-01T00:00:00.000Z');
|
|
546
|
+
|
|
547
|
+
// Test all log levels
|
|
548
|
+
const levels = [LogLevel.Trace, LogLevel.Debug, LogLevel.Info, LogLevel.Warning, LogLevel.Error, LogLevel.Fatal];
|
|
549
|
+
|
|
550
|
+
levels.forEach(level => {
|
|
551
|
+
const out = formatter.format({
|
|
552
|
+
level,
|
|
553
|
+
message: 'test',
|
|
554
|
+
timestamp,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const parsed = JSON.parse(out);
|
|
558
|
+
expect(parsed.level).toBeDefined();
|
|
559
|
+
expect(parsed.message).toBe('test');
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('PrettyFormatter should handle complex nested structures with formatting', () => {
|
|
564
|
+
const formatter = new PrettyFormatter();
|
|
565
|
+
|
|
566
|
+
const complexData = {
|
|
567
|
+
level1: {
|
|
568
|
+
level2: {
|
|
569
|
+
level3: {
|
|
570
|
+
deep: 'value',
|
|
571
|
+
array: [1, 2, { nested: true }],
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const out = formatter.format({
|
|
578
|
+
level: LogLevel.Info,
|
|
579
|
+
message: 'complex',
|
|
580
|
+
timestamp: new Date('2025-01-01T00:00:00.000Z'),
|
|
581
|
+
context: complexData,
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
expect(out).toContain('level1');
|
|
585
|
+
expect(out).toContain('level2');
|
|
586
|
+
expect(out).toContain('level3');
|
|
587
|
+
});
|
|
588
|
+
});
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Context,
|
|
3
|
+
Effect,
|
|
4
|
+
FiberRef,
|
|
5
|
+
Layer,
|
|
6
|
+
} from 'effect';
|
|
7
|
+
|
|
8
|
+
import { JsonFormatter, PrettyFormatter } from './formatter';
|
|
9
|
+
import { ConsoleTransport } from './transport';
|
|
10
|
+
import {
|
|
11
|
+
type LoggerConfig,
|
|
12
|
+
LogLevel,
|
|
13
|
+
type TraceInfo,
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Custom logger service interface
|
|
18
|
+
*/
|
|
19
|
+
export type Logger = {
|
|
20
|
+
trace(message: string, ...args: unknown[]): Effect.Effect<void>;
|
|
21
|
+
debug(message: string, ...args: unknown[]): Effect.Effect<void>;
|
|
22
|
+
info(message: string, ...args: unknown[]): Effect.Effect<void>;
|
|
23
|
+
warn(message: string, ...args: unknown[]): Effect.Effect<void>;
|
|
24
|
+
error(message: string, ...args: unknown[]): Effect.Effect<void>;
|
|
25
|
+
fatal(message: string, ...args: unknown[]): Effect.Effect<void>;
|
|
26
|
+
child(context: Record<string, unknown>): Logger;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Synchronous logger interface for convenience
|
|
31
|
+
*/
|
|
32
|
+
export type SyncLogger = {
|
|
33
|
+
trace(message: string, ...args: unknown[]): void;
|
|
34
|
+
debug(message: string, ...args: unknown[]): void;
|
|
35
|
+
info(message: string, ...args: unknown[]): void;
|
|
36
|
+
warn(message: string, ...args: unknown[]): void;
|
|
37
|
+
error(message: string, ...args: unknown[]): void;
|
|
38
|
+
fatal(message: string, ...args: unknown[]): void;
|
|
39
|
+
child(context: Record<string, unknown>): SyncLogger;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Context tag for the Logger service
|
|
44
|
+
*/
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
46
|
+
export const LoggerService = Context.GenericTag<Logger>('LoggerService');
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Current trace context stored in fiber for automatic trace inclusion in logs
|
|
50
|
+
*/
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
52
|
+
export const CurrentLoggerTraceContext = FiberRef.unsafeMake<TraceInfo | null>(null);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse arguments to extract error, context and additional data
|
|
56
|
+
*/
|
|
57
|
+
function parseLogArgs(args: unknown[]): {
|
|
58
|
+
error?: Error;
|
|
59
|
+
context?: Record<string, unknown>;
|
|
60
|
+
additionalData?: unknown[];
|
|
61
|
+
} {
|
|
62
|
+
if (args.length === 0) {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let error: Error | undefined;
|
|
67
|
+
let context: Record<string, unknown> | undefined;
|
|
68
|
+
const additionalData: unknown[] = [];
|
|
69
|
+
|
|
70
|
+
for (const arg of args) {
|
|
71
|
+
if (arg instanceof Error) {
|
|
72
|
+
// First error found becomes the main error
|
|
73
|
+
if (!error) {
|
|
74
|
+
error = arg;
|
|
75
|
+
} else {
|
|
76
|
+
additionalData.push(arg);
|
|
77
|
+
}
|
|
78
|
+
} else if (
|
|
79
|
+
arg &&
|
|
80
|
+
typeof arg === 'object' &&
|
|
81
|
+
!Array.isArray(arg) &&
|
|
82
|
+
arg.constructor === Object
|
|
83
|
+
) {
|
|
84
|
+
// Plain objects are merged into context
|
|
85
|
+
context = { ...context, ...(arg as Record<string, unknown>) };
|
|
86
|
+
} else {
|
|
87
|
+
// Everything else goes to additional data
|
|
88
|
+
additionalData.push(arg);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
error,
|
|
94
|
+
context: Object.keys(context || {}).length > 0 ? context : undefined,
|
|
95
|
+
additionalData: additionalData.length > 0 ? additionalData : undefined,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Simple logger implementation that uses our formatters and transports
|
|
101
|
+
*/
|
|
102
|
+
class LoggerImpl implements Logger {
|
|
103
|
+
constructor(
|
|
104
|
+
private config: LoggerConfig,
|
|
105
|
+
private context: Record<string, unknown> = {},
|
|
106
|
+
) {}
|
|
107
|
+
|
|
108
|
+
private log(level: LogLevel, message: string, ...args: unknown[]): Effect.Effect<void> {
|
|
109
|
+
// Check minimum logging level
|
|
110
|
+
if (level < this.config.minLevel) {
|
|
111
|
+
return Effect.succeed(undefined);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return Effect.flatMap(FiberRef.get(CurrentLoggerTraceContext), (traceInfo) => {
|
|
115
|
+
// Try to get trace context from global context or trace service
|
|
116
|
+
let currentTraceInfo = traceInfo;
|
|
117
|
+
if (!currentTraceInfo && typeof globalThis !== 'undefined') {
|
|
118
|
+
// Try global trace context first
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
120
|
+
const globalTraceContext = (globalThis as any).__onebunCurrentTraceContext;
|
|
121
|
+
if (globalTraceContext && globalTraceContext.traceId) {
|
|
122
|
+
currentTraceInfo = globalTraceContext;
|
|
123
|
+
} else {
|
|
124
|
+
// Fallback to trace service
|
|
125
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
126
|
+
const globalTraceService = (globalThis as any).__onebunTraceService;
|
|
127
|
+
if (globalTraceService && globalTraceService.getCurrentTraceContext) {
|
|
128
|
+
try {
|
|
129
|
+
// Extract current trace context from global trace service
|
|
130
|
+
|
|
131
|
+
const currentContext = Effect.runSync(
|
|
132
|
+
globalTraceService.getCurrentTraceContext(),
|
|
133
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
134
|
+
) as any;
|
|
135
|
+
if (currentContext && currentContext.traceId) {
|
|
136
|
+
currentTraceInfo = {
|
|
137
|
+
traceId: currentContext.traceId,
|
|
138
|
+
spanId: currentContext.spanId,
|
|
139
|
+
parentSpanId: currentContext.parentSpanId,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Ignore errors getting trace context
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Parse additional arguments
|
|
150
|
+
const { error, context: argsContext, additionalData } = parseLogArgs(args);
|
|
151
|
+
|
|
152
|
+
// Merge contexts
|
|
153
|
+
const mergedContext = {
|
|
154
|
+
...this.config.defaultContext,
|
|
155
|
+
...this.context,
|
|
156
|
+
...argsContext,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Add additional data to context if present
|
|
160
|
+
if (additionalData && additionalData.length > 0) {
|
|
161
|
+
mergedContext.__additionalData = additionalData;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Create log entry
|
|
165
|
+
const entry = {
|
|
166
|
+
level,
|
|
167
|
+
message,
|
|
168
|
+
timestamp: new Date(),
|
|
169
|
+
context: Object.keys(mergedContext).length > 0 ? mergedContext : undefined,
|
|
170
|
+
error,
|
|
171
|
+
trace: currentTraceInfo || undefined,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Format and send through transport
|
|
175
|
+
const formattedEntry = this.config.formatter.format(entry);
|
|
176
|
+
|
|
177
|
+
return this.config.transport.log(formattedEntry, entry);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
trace(message: string, ...args: unknown[]): Effect.Effect<void> {
|
|
182
|
+
return this.log(LogLevel.Trace, message, ...args);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
debug(message: string, ...args: unknown[]): Effect.Effect<void> {
|
|
186
|
+
return this.log(LogLevel.Debug, message, ...args);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
info(message: string, ...args: unknown[]): Effect.Effect<void> {
|
|
190
|
+
return this.log(LogLevel.Info, message, ...args);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
warn(message: string, ...args: unknown[]): Effect.Effect<void> {
|
|
194
|
+
return this.log(LogLevel.Warning, message, ...args);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
error(message: string, ...args: unknown[]): Effect.Effect<void> {
|
|
198
|
+
return this.log(LogLevel.Error, message, ...args);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fatal(message: string, ...args: unknown[]): Effect.Effect<void> {
|
|
202
|
+
return this.log(LogLevel.Fatal, message, ...args);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
child(context: Record<string, unknown>): Logger {
|
|
206
|
+
return new LoggerImpl(this.config, { ...this.context, ...context });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Creates a development logger with pretty console output
|
|
212
|
+
*/
|
|
213
|
+
export const makeDevLogger = (config?: Partial<LoggerConfig>): Layer.Layer<Logger> => {
|
|
214
|
+
return Layer.succeed(
|
|
215
|
+
LoggerService,
|
|
216
|
+
new LoggerImpl({
|
|
217
|
+
minLevel: LogLevel.Debug,
|
|
218
|
+
formatter: new PrettyFormatter(),
|
|
219
|
+
transport: new ConsoleTransport(),
|
|
220
|
+
defaultContext: {},
|
|
221
|
+
...config,
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Creates a production logger with JSON output
|
|
228
|
+
*/
|
|
229
|
+
export const makeProdLogger = (config?: Partial<LoggerConfig>): Layer.Layer<Logger> => {
|
|
230
|
+
return Layer.succeed(
|
|
231
|
+
LoggerService,
|
|
232
|
+
new LoggerImpl({
|
|
233
|
+
minLevel: LogLevel.Info,
|
|
234
|
+
formatter: new JsonFormatter(),
|
|
235
|
+
transport: new ConsoleTransport(),
|
|
236
|
+
defaultContext: {},
|
|
237
|
+
...config,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Synchronous logger wrapper
|
|
244
|
+
*/
|
|
245
|
+
class SyncLoggerImpl implements SyncLogger {
|
|
246
|
+
constructor(private logger: Logger) {}
|
|
247
|
+
|
|
248
|
+
private runWithTraceContext<T>(effect: Effect.Effect<T>): T {
|
|
249
|
+
// Try to get trace context from global context
|
|
250
|
+
let traceEffect = effect;
|
|
251
|
+
if (typeof globalThis !== 'undefined') {
|
|
252
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
253
|
+
const globalTraceContext = (globalThis as any).__onebunCurrentTraceContext;
|
|
254
|
+
if (globalTraceContext && globalTraceContext.traceId) {
|
|
255
|
+
traceEffect = Effect.provide(
|
|
256
|
+
Effect.flatMap(FiberRef.set(CurrentLoggerTraceContext, globalTraceContext), () => effect),
|
|
257
|
+
Layer.empty,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return Effect.runSync(traceEffect);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
trace(message: string, ...args: unknown[]): void {
|
|
266
|
+
this.runWithTraceContext(this.logger.trace(message, ...args));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
debug(message: string, ...args: unknown[]): void {
|
|
270
|
+
this.runWithTraceContext(this.logger.debug(message, ...args));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
info(message: string, ...args: unknown[]): void {
|
|
274
|
+
this.runWithTraceContext(this.logger.info(message, ...args));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
warn(message: string, ...args: unknown[]): void {
|
|
278
|
+
this.runWithTraceContext(this.logger.warn(message, ...args));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
error(message: string, ...args: unknown[]): void {
|
|
282
|
+
this.runWithTraceContext(this.logger.error(message, ...args));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
fatal(message: string, ...args: unknown[]): void {
|
|
286
|
+
this.runWithTraceContext(this.logger.fatal(message, ...args));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
child(context: Record<string, unknown>): SyncLogger {
|
|
290
|
+
return new SyncLoggerImpl(this.logger.child(context));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Create a synchronous logger from async logger
|
|
296
|
+
*/
|
|
297
|
+
export const createSyncLogger = (logger: Logger): SyncLogger => {
|
|
298
|
+
return new SyncLoggerImpl(logger);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Create a logger based on NODE_ENV
|
|
303
|
+
*/
|
|
304
|
+
export const makeLogger = (config?: Partial<LoggerConfig>): Layer.Layer<Logger> => {
|
|
305
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
306
|
+
|
|
307
|
+
return isDev ? makeDevLogger(config) : makeProdLogger(config);
|
|
308
|
+
};
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type LogEntry,
|
|
5
|
+
LogLevel,
|
|
6
|
+
type LogTransport,
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Console transport for logging
|
|
11
|
+
*/
|
|
12
|
+
export class ConsoleTransport implements LogTransport {
|
|
13
|
+
log(formattedEntry: string, entry: LogEntry): Effect.Effect<void> {
|
|
14
|
+
return Effect.sync(() => {
|
|
15
|
+
switch (entry.level) {
|
|
16
|
+
case LogLevel.Error:
|
|
17
|
+
case LogLevel.Fatal:
|
|
18
|
+
// eslint-disable-next-line no-console
|
|
19
|
+
console.error(formattedEntry);
|
|
20
|
+
break;
|
|
21
|
+
case LogLevel.Warning:
|
|
22
|
+
// eslint-disable-next-line no-console
|
|
23
|
+
console.warn(formattedEntry);
|
|
24
|
+
break;
|
|
25
|
+
case LogLevel.Info:
|
|
26
|
+
// eslint-disable-next-line no-console
|
|
27
|
+
console.info(formattedEntry);
|
|
28
|
+
break;
|
|
29
|
+
case LogLevel.Debug:
|
|
30
|
+
case LogLevel.Trace:
|
|
31
|
+
default:
|
|
32
|
+
// eslint-disable-next-line no-console
|
|
33
|
+
console.log(formattedEntry);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Effect } from 'effect';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Определяем уровни логирования
|
|
5
|
+
*/
|
|
6
|
+
/* eslint-disable no-magic-numbers -- Standard log levels based on syslog defined in one place */
|
|
7
|
+
export enum LogLevel {
|
|
8
|
+
Fatal = 60,
|
|
9
|
+
Error = 50,
|
|
10
|
+
Warning = 40,
|
|
11
|
+
Info = 30,
|
|
12
|
+
Debug = 20,
|
|
13
|
+
Trace = 10,
|
|
14
|
+
None = 0,
|
|
15
|
+
}
|
|
16
|
+
/* eslint-enable no-magic-numbers */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Trace information for log entries
|
|
20
|
+
*/
|
|
21
|
+
export interface TraceInfo {
|
|
22
|
+
traceId: string;
|
|
23
|
+
spanId: string;
|
|
24
|
+
parentSpanId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Log entry structure
|
|
29
|
+
*/
|
|
30
|
+
export interface LogEntry {
|
|
31
|
+
level: LogLevel;
|
|
32
|
+
message: string;
|
|
33
|
+
timestamp: Date;
|
|
34
|
+
context?: Record<string, unknown>;
|
|
35
|
+
error?: Error;
|
|
36
|
+
trace?: TraceInfo;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Log formatter interface
|
|
41
|
+
*/
|
|
42
|
+
export interface LogFormatter {
|
|
43
|
+
format(entry: LogEntry): string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Log transport interface
|
|
48
|
+
*/
|
|
49
|
+
export interface LogTransport {
|
|
50
|
+
log(formattedEntry: string, entry: LogEntry): Effect.Effect<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Logger configuration
|
|
55
|
+
*/
|
|
56
|
+
export interface LoggerConfig {
|
|
57
|
+
minLevel: LogLevel;
|
|
58
|
+
formatter: LogFormatter;
|
|
59
|
+
transport: LogTransport;
|
|
60
|
+
defaultContext?: Record<string, unknown>;
|
|
61
|
+
}
|