@sprqvntrs/logger 1.0.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/LICENSE +21 -0
- package/README.md +311 -0
- package/index.ts +53 -0
- package/package.json +54 -0
- package/src/context/async-context.ts +126 -0
- package/src/core/buffered-logger.ts +210 -0
- package/src/core/destinations.ts +63 -0
- package/src/core/logger.ts +208 -0
- package/src/core/pino-config.ts +92 -0
- package/src/core/serializers.ts +81 -0
- package/src/middleware/http.ts +225 -0
- package/src/presets/server.ts +206 -0
- package/src/testing/mock-logger.ts +234 -0
- package/src/types.ts +198 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import pinoHttp from 'pino-http';
|
|
2
|
+
import type { Options as PinoHttpOptions } from 'pino-http';
|
|
3
|
+
import type { HttpLoggerOptions, LogLevel } from '../types';
|
|
4
|
+
import { createLogger } from '../core/logger';
|
|
5
|
+
import {
|
|
6
|
+
withRequestContext,
|
|
7
|
+
generateRequestId,
|
|
8
|
+
} from '../context/async-context';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Common static file extensions to exclude from logging
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_EXCLUDE_EXTENSIONS = [
|
|
14
|
+
'.js',
|
|
15
|
+
'.css',
|
|
16
|
+
'.png',
|
|
17
|
+
'.jpg',
|
|
18
|
+
'.jpeg',
|
|
19
|
+
'.gif',
|
|
20
|
+
'.ico',
|
|
21
|
+
'.svg',
|
|
22
|
+
'.woff',
|
|
23
|
+
'.woff2',
|
|
24
|
+
'.ttf',
|
|
25
|
+
'.eot',
|
|
26
|
+
'.map',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Common paths to exclude from logging
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_EXCLUDE_PATHS = ['/health', '/healthz', '/ready', '/metrics', '/favicon.ico'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Determines log level based on response status code
|
|
36
|
+
*/
|
|
37
|
+
function defaultCustomLogLevel(
|
|
38
|
+
_req: unknown,
|
|
39
|
+
res: { statusCode?: number },
|
|
40
|
+
err?: Error
|
|
41
|
+
): LogLevel {
|
|
42
|
+
if (err || (res.statusCode && res.statusCode >= 500)) {
|
|
43
|
+
return 'error';
|
|
44
|
+
}
|
|
45
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
46
|
+
return 'warn';
|
|
47
|
+
}
|
|
48
|
+
return 'info';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if a path should be excluded from logging
|
|
53
|
+
*/
|
|
54
|
+
function shouldExcludePath(url: string, excludePaths: string[]): boolean {
|
|
55
|
+
// Remove query string for path matching
|
|
56
|
+
const pathname = url.split('?')[0] ?? url;
|
|
57
|
+
return excludePaths.some((path) => pathname === path || pathname.startsWith(`${path}/`));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if a URL has an excluded extension
|
|
62
|
+
*/
|
|
63
|
+
function shouldExcludeExtension(url: string, excludeExtensions: string[]): boolean {
|
|
64
|
+
const pathname = url.split('?')[0] ?? url;
|
|
65
|
+
return excludeExtensions.some((ext) => pathname.endsWith(ext));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface HttpRequest {
|
|
69
|
+
id?: string;
|
|
70
|
+
method?: string;
|
|
71
|
+
url?: string;
|
|
72
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface HttpResponse {
|
|
76
|
+
statusCode?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Creates HTTP logging middleware using pino-http
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* import { createHttpLogger } from '@sprqvntrs/logger/http';
|
|
85
|
+
* import { createLogger } from '@sprqvntrs/logger';
|
|
86
|
+
*
|
|
87
|
+
* const logger = createLogger({ serviceName: 'api' });
|
|
88
|
+
* const httpLogger = createHttpLogger({
|
|
89
|
+
* logger,
|
|
90
|
+
* excludePaths: ['/health', '/metrics'],
|
|
91
|
+
* });
|
|
92
|
+
*
|
|
93
|
+
* // Express
|
|
94
|
+
* app.use(httpLogger);
|
|
95
|
+
*
|
|
96
|
+
* // Fastify
|
|
97
|
+
* fastify.addHook('onRequest', (req, reply, done) => {
|
|
98
|
+
* httpLogger(req.raw, reply.raw, done);
|
|
99
|
+
* });
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export function createHttpLogger(options: HttpLoggerOptions = {}) {
|
|
103
|
+
const excludePaths = [...DEFAULT_EXCLUDE_PATHS, ...(options.excludePaths ?? [])];
|
|
104
|
+
const excludeExtensions = [
|
|
105
|
+
...DEFAULT_EXCLUDE_EXTENSIONS,
|
|
106
|
+
...(options.excludeExtensions ?? []),
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
// Use provided logger or create a default one
|
|
110
|
+
const logger =
|
|
111
|
+
options.logger ??
|
|
112
|
+
createLogger({
|
|
113
|
+
serviceName: 'http',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const customLogLevel = options.customLogLevel ?? defaultCustomLogLevel;
|
|
117
|
+
|
|
118
|
+
const pinoHttpOptions: PinoHttpOptions = {
|
|
119
|
+
logger: logger.pino,
|
|
120
|
+
|
|
121
|
+
// Generate request ID if not present
|
|
122
|
+
genReqId: (req) => {
|
|
123
|
+
const httpReq = req as HttpRequest;
|
|
124
|
+
const existingId = httpReq.headers?.['x-request-id'];
|
|
125
|
+
if (typeof existingId === 'string') {
|
|
126
|
+
return existingId;
|
|
127
|
+
}
|
|
128
|
+
return generateRequestId();
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
// Control auto-logging based on path/extension exclusions
|
|
132
|
+
autoLogging: {
|
|
133
|
+
ignore: (req) => {
|
|
134
|
+
const httpReq = req as HttpRequest;
|
|
135
|
+
const url = httpReq.url ?? '';
|
|
136
|
+
return (
|
|
137
|
+
shouldExcludePath(url, excludePaths) ||
|
|
138
|
+
shouldExcludeExtension(url, excludeExtensions)
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// Custom serializers for request/response
|
|
144
|
+
serializers: {
|
|
145
|
+
req: (req) => {
|
|
146
|
+
const httpReq = req as HttpRequest;
|
|
147
|
+
return {
|
|
148
|
+
id: httpReq.id,
|
|
149
|
+
method: httpReq.method,
|
|
150
|
+
url: httpReq.url,
|
|
151
|
+
// Only include safe headers
|
|
152
|
+
headers: httpReq.headers
|
|
153
|
+
? {
|
|
154
|
+
'user-agent': httpReq.headers['user-agent'],
|
|
155
|
+
'content-type': httpReq.headers['content-type'],
|
|
156
|
+
'content-length': httpReq.headers['content-length'],
|
|
157
|
+
host: httpReq.headers['host'],
|
|
158
|
+
}
|
|
159
|
+
: undefined,
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
res: (res) => {
|
|
163
|
+
const httpRes = res as HttpResponse;
|
|
164
|
+
return {
|
|
165
|
+
statusCode: httpRes.statusCode,
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// Status-based log level
|
|
171
|
+
customLogLevel: (req, res, err) => {
|
|
172
|
+
return customLogLevel(req, res as HttpResponse, err);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
// Custom success message
|
|
176
|
+
customSuccessMessage: (req, res) => {
|
|
177
|
+
const httpReq = req as HttpRequest;
|
|
178
|
+
const httpRes = res as HttpResponse;
|
|
179
|
+
return `${httpReq.method} ${httpReq.url} ${httpRes.statusCode}`;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// Custom error message
|
|
183
|
+
customErrorMessage: (req, res, err) => {
|
|
184
|
+
const httpReq = req as HttpRequest;
|
|
185
|
+
const httpRes = res as HttpResponse;
|
|
186
|
+
return `${httpReq.method} ${httpReq.url} ${httpRes.statusCode} - ${err.message}`;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
// Add custom properties to each log
|
|
190
|
+
customProps: options.customProps
|
|
191
|
+
? (req, res) => options.customProps!(req, res)
|
|
192
|
+
: undefined,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const middleware = pinoHttp(pinoHttpOptions);
|
|
196
|
+
|
|
197
|
+
// Wrap middleware to set up request context
|
|
198
|
+
return (req: unknown, res: unknown, next: () => void) => {
|
|
199
|
+
const httpReq = req as HttpRequest;
|
|
200
|
+
const requestId =
|
|
201
|
+
(typeof httpReq.headers?.['x-request-id'] === 'string'
|
|
202
|
+
? httpReq.headers['x-request-id']
|
|
203
|
+
: undefined) ?? generateRequestId();
|
|
204
|
+
|
|
205
|
+
// Set request ID on request object
|
|
206
|
+
(req as { id?: string }).id = requestId;
|
|
207
|
+
|
|
208
|
+
// Run in request context so all logs include requestId
|
|
209
|
+
withRequestContext({ requestId }, () => {
|
|
210
|
+
middleware(req as Parameters<typeof middleware>[0], res as Parameters<typeof middleware>[1], next);
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Extract request ID from incoming request headers
|
|
217
|
+
* Falls back to generating a new ID
|
|
218
|
+
*/
|
|
219
|
+
export function extractRequestId(req: HttpRequest): string {
|
|
220
|
+
const headerValue = req.headers?.['x-request-id'];
|
|
221
|
+
if (typeof headerValue === 'string') {
|
|
222
|
+
return headerValue;
|
|
223
|
+
}
|
|
224
|
+
return generateRequestId();
|
|
225
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createLogger } from '../core/logger';
|
|
2
|
+
import type { Logger, ServerLogger, CreateLoggerOptions, LogLevel } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for creating a server logger
|
|
6
|
+
*/
|
|
7
|
+
export interface CreateServerLoggerOptions extends Omit<CreateLoggerOptions, 'serviceName'> {
|
|
8
|
+
/** Service name (required) */
|
|
9
|
+
serviceName: string;
|
|
10
|
+
/** Whether to register global error handlers (default: false) */
|
|
11
|
+
registerGlobalHandlers?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a server logger with lifecycle logging methods
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import { createServerLogger } from '@sprqvntrs/logger/server';
|
|
20
|
+
*
|
|
21
|
+
* const { logger, logServerStart, logShutdown, logServerClosed } =
|
|
22
|
+
* createServerLogger({ serviceName: 'api' });
|
|
23
|
+
*
|
|
24
|
+
* const server = app.listen(3000, () => {
|
|
25
|
+
* logServerStart(3000, { url: 'http://localhost:3000' });
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* process.on('SIGTERM', () => {
|
|
29
|
+
* logShutdown('SIGTERM');
|
|
30
|
+
* server.close(() => logServerClosed());
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function createServerLogger(options: CreateServerLoggerOptions): ServerLogger {
|
|
35
|
+
const { serviceName, registerGlobalHandlers, ...loggerOptions } = options;
|
|
36
|
+
const logger = createLogger({ serviceName, ...loggerOptions });
|
|
37
|
+
|
|
38
|
+
const serverLogger: ServerLogger = {
|
|
39
|
+
logger,
|
|
40
|
+
|
|
41
|
+
logServerStart(port: number, metadata?: Record<string, unknown>): void {
|
|
42
|
+
logger.info(`${serviceName} started`, {
|
|
43
|
+
port,
|
|
44
|
+
pid: process.pid,
|
|
45
|
+
nodeVersion: process.version,
|
|
46
|
+
...metadata,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
logShutdown(signal: string): void {
|
|
51
|
+
logger.info(`${serviceName} received shutdown signal`, { signal });
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
logServerClosed(): void {
|
|
55
|
+
logger.info(`${serviceName} server closed`);
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
logUncaughtException(error: Error): void {
|
|
59
|
+
logger.fatal(`${serviceName} uncaught exception`, { error });
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
logUnhandledRejection(reason: unknown): void {
|
|
63
|
+
logger.error(`${serviceName} unhandled rejection`, {
|
|
64
|
+
error: reason instanceof Error ? reason : String(reason),
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Optionally register global error handlers
|
|
70
|
+
if (registerGlobalHandlers) {
|
|
71
|
+
process.on('uncaughtException', (error) => {
|
|
72
|
+
serverLogger.logUncaughtException(error);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
process.on('unhandledRejection', (reason) => {
|
|
77
|
+
serverLogger.logUnhandledRejection(reason);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return serverLogger;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Standalone server lifecycle logging functions
|
|
86
|
+
* For when you want to use an existing logger
|
|
87
|
+
*/
|
|
88
|
+
export function createLifecycleLoggers(logger: Logger, serviceName: string) {
|
|
89
|
+
return {
|
|
90
|
+
logServerStart: (port: number, metadata?: Record<string, unknown>) => {
|
|
91
|
+
logger.info(`${serviceName} started`, {
|
|
92
|
+
port,
|
|
93
|
+
pid: process.pid,
|
|
94
|
+
nodeVersion: process.version,
|
|
95
|
+
...metadata,
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
logShutdown: (signal: string) => {
|
|
100
|
+
logger.info(`${serviceName} received shutdown signal`, { signal });
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
logServerClosed: () => {
|
|
104
|
+
logger.info(`${serviceName} server closed`);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
logUncaughtException: (error: Error) => {
|
|
108
|
+
logger.fatal(`${serviceName} uncaught exception`, { error });
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
logUnhandledRejection: (reason: unknown) => {
|
|
112
|
+
logger.error(`${serviceName} unhandled rejection`, {
|
|
113
|
+
error: reason instanceof Error ? reason : String(reason),
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Creates a worker/background job logger with job-specific logging
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```typescript
|
|
124
|
+
* const { logger, logJobStart, logJobComplete, logJobFailed } =
|
|
125
|
+
* createWorkerLogger({ serviceName: 'background-worker' });
|
|
126
|
+
*
|
|
127
|
+
* async function processJob(job: Job) {
|
|
128
|
+
* const startTime = Date.now();
|
|
129
|
+
* logJobStart(job.id, job.type);
|
|
130
|
+
*
|
|
131
|
+
* try {
|
|
132
|
+
* await executeJob(job);
|
|
133
|
+
* logJobComplete(job.id, job.type, Date.now() - startTime);
|
|
134
|
+
* } catch (error) {
|
|
135
|
+
* logJobFailed(job.id, job.type, error);
|
|
136
|
+
* throw error;
|
|
137
|
+
* }
|
|
138
|
+
* }
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
export function createWorkerLogger(options: CreateServerLoggerOptions) {
|
|
142
|
+
const { serviceName, ...loggerOptions } = options;
|
|
143
|
+
const logger = createLogger({ serviceName, ...loggerOptions });
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
logger,
|
|
147
|
+
|
|
148
|
+
logJobStart: (jobId: string, jobType: string, metadata?: Record<string, unknown>) => {
|
|
149
|
+
logger.info('Job started', { jobId, jobType, ...metadata });
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
logJobComplete: (
|
|
153
|
+
jobId: string,
|
|
154
|
+
jobType: string,
|
|
155
|
+
durationMs: number,
|
|
156
|
+
metadata?: Record<string, unknown>
|
|
157
|
+
) => {
|
|
158
|
+
logger.info('Job completed', {
|
|
159
|
+
jobId,
|
|
160
|
+
jobType,
|
|
161
|
+
duration: durationMs,
|
|
162
|
+
...metadata,
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
logJobFailed: (
|
|
167
|
+
jobId: string,
|
|
168
|
+
jobType: string,
|
|
169
|
+
error: unknown,
|
|
170
|
+
metadata?: Record<string, unknown>
|
|
171
|
+
) => {
|
|
172
|
+
logger.error('Job failed', {
|
|
173
|
+
jobId,
|
|
174
|
+
jobType,
|
|
175
|
+
error: error instanceof Error ? error : String(error),
|
|
176
|
+
...metadata,
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
logJobRetry: (
|
|
181
|
+
jobId: string,
|
|
182
|
+
jobType: string,
|
|
183
|
+
attempt: number,
|
|
184
|
+
maxAttempts: number,
|
|
185
|
+
error: unknown
|
|
186
|
+
) => {
|
|
187
|
+
logger.warn('Job retry', {
|
|
188
|
+
jobId,
|
|
189
|
+
jobType,
|
|
190
|
+
attempt,
|
|
191
|
+
maxAttempts,
|
|
192
|
+
error: error instanceof Error ? error : String(error),
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Log level appropriate for different HTTP status codes
|
|
200
|
+
* Useful for custom log level functions
|
|
201
|
+
*/
|
|
202
|
+
export function getLogLevelForStatus(statusCode: number): LogLevel {
|
|
203
|
+
if (statusCode >= 500) return 'error';
|
|
204
|
+
if (statusCode >= 400) return 'warn';
|
|
205
|
+
return 'info';
|
|
206
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
import type { Logger, MockLogger, LogEntry, LogLevel, LogContext } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a mock logger for testing purposes
|
|
6
|
+
* Captures all log entries for assertion
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { createMockLogger } from '@sprqvntrs/logger/testing';
|
|
11
|
+
*
|
|
12
|
+
* describe('MyService', () => {
|
|
13
|
+
* let logger: MockLogger;
|
|
14
|
+
*
|
|
15
|
+
* beforeEach(() => {
|
|
16
|
+
* logger = createMockLogger();
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* it('logs user creation', () => {
|
|
20
|
+
* const service = new MyService(logger);
|
|
21
|
+
* service.createUser({ name: 'Test' });
|
|
22
|
+
*
|
|
23
|
+
* expect(logger.hasLog((log) =>
|
|
24
|
+
* log.level === 'info' &&
|
|
25
|
+
* log.message.includes('User created')
|
|
26
|
+
* )).toBe(true);
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* it('logs errors with context', () => {
|
|
30
|
+
* const service = new MyService(logger);
|
|
31
|
+
* service.failingOperation();
|
|
32
|
+
*
|
|
33
|
+
* const errorLogs = logger.getLogsByLevel('error');
|
|
34
|
+
* expect(errorLogs).toHaveLength(1);
|
|
35
|
+
* expect(errorLogs[0]?.context?.error).toBeDefined();
|
|
36
|
+
* });
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function createMockLogger(): MockLogger {
|
|
41
|
+
const logs: LogEntry[] = [];
|
|
42
|
+
|
|
43
|
+
// Create a silent pino instance for the pino property
|
|
44
|
+
const silentPino = pino({ level: 'silent' });
|
|
45
|
+
|
|
46
|
+
function createLogMethod(level: LogLevel) {
|
|
47
|
+
return (message: string, context?: LogContext) => {
|
|
48
|
+
logs.push({
|
|
49
|
+
level,
|
|
50
|
+
message,
|
|
51
|
+
context,
|
|
52
|
+
timestamp: new Date(),
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const mockLogger: MockLogger = {
|
|
58
|
+
trace: createLogMethod('trace'),
|
|
59
|
+
debug: createLogMethod('debug'),
|
|
60
|
+
info: createLogMethod('info'),
|
|
61
|
+
warn: createLogMethod('warn'),
|
|
62
|
+
error: createLogMethod('error'),
|
|
63
|
+
fatal: createLogMethod('fatal'),
|
|
64
|
+
|
|
65
|
+
child(bindings: Record<string, unknown>): Logger {
|
|
66
|
+
// Create a child mock that merges bindings into context
|
|
67
|
+
const childLogs: LogEntry[] = [];
|
|
68
|
+
|
|
69
|
+
function createChildLogMethod(level: LogLevel) {
|
|
70
|
+
return (message: string, context?: LogContext) => {
|
|
71
|
+
const entry: LogEntry = {
|
|
72
|
+
level,
|
|
73
|
+
message,
|
|
74
|
+
context: { ...bindings, ...context },
|
|
75
|
+
timestamp: new Date(),
|
|
76
|
+
};
|
|
77
|
+
childLogs.push(entry);
|
|
78
|
+
// Also add to parent logs
|
|
79
|
+
logs.push(entry);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Return a simplified child (not full MockLogger to avoid complexity)
|
|
84
|
+
return {
|
|
85
|
+
trace: createChildLogMethod('trace'),
|
|
86
|
+
debug: createChildLogMethod('debug'),
|
|
87
|
+
info: createChildLogMethod('info'),
|
|
88
|
+
warn: createChildLogMethod('warn'),
|
|
89
|
+
error: createChildLogMethod('error'),
|
|
90
|
+
fatal: createChildLogMethod('fatal'),
|
|
91
|
+
child: (moreBindings) =>
|
|
92
|
+
mockLogger.child({ ...bindings, ...moreBindings }),
|
|
93
|
+
pino: silentPino,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
get pino() {
|
|
98
|
+
return silentPino;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// Test utility methods
|
|
102
|
+
getLogs(): LogEntry[] {
|
|
103
|
+
return [...logs];
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
clear(): void {
|
|
107
|
+
logs.length = 0;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
hasLog(predicate: (log: LogEntry) => boolean): boolean {
|
|
111
|
+
return logs.some(predicate);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
getLogsByLevel(level: LogLevel): LogEntry[] {
|
|
115
|
+
return logs.filter((log) => log.level === level);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
getLogsByMessage(pattern: string | RegExp): LogEntry[] {
|
|
119
|
+
if (typeof pattern === 'string') {
|
|
120
|
+
return logs.filter((log) => log.message.includes(pattern));
|
|
121
|
+
}
|
|
122
|
+
return logs.filter((log) => pattern.test(log.message));
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return mockLogger;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Creates a spy logger that wraps a real logger and captures calls
|
|
131
|
+
* Useful when you want real logging behavior but also want to assert on calls
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```typescript
|
|
135
|
+
* const realLogger = createLogger({ serviceName: 'test' });
|
|
136
|
+
* const { logger, getCalls } = createSpyLogger(realLogger);
|
|
137
|
+
*
|
|
138
|
+
* // Use logger normally - it will log AND capture calls
|
|
139
|
+
* logger.info('test message');
|
|
140
|
+
*
|
|
141
|
+
* // Assert on calls
|
|
142
|
+
* expect(getCalls('info')).toContainEqual({
|
|
143
|
+
* message: 'test message',
|
|
144
|
+
* context: undefined,
|
|
145
|
+
* });
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export function createSpyLogger(realLogger: Logger) {
|
|
149
|
+
const calls: Record<LogLevel, Array<{ message: string; context?: LogContext }>> = {
|
|
150
|
+
trace: [],
|
|
151
|
+
debug: [],
|
|
152
|
+
info: [],
|
|
153
|
+
warn: [],
|
|
154
|
+
error: [],
|
|
155
|
+
fatal: [],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function createSpyMethod(level: LogLevel) {
|
|
159
|
+
return (message: string, context?: LogContext) => {
|
|
160
|
+
calls[level].push({ message, context });
|
|
161
|
+
realLogger[level](message, context);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const spyLogger: Logger = {
|
|
166
|
+
trace: createSpyMethod('trace'),
|
|
167
|
+
debug: createSpyMethod('debug'),
|
|
168
|
+
info: createSpyMethod('info'),
|
|
169
|
+
warn: createSpyMethod('warn'),
|
|
170
|
+
error: createSpyMethod('error'),
|
|
171
|
+
fatal: createSpyMethod('fatal'),
|
|
172
|
+
child: (bindings) => createSpyLogger(realLogger.child(bindings)).logger,
|
|
173
|
+
pino: realLogger.pino,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
logger: spyLogger,
|
|
178
|
+
getCalls: (level: LogLevel) => [...calls[level]],
|
|
179
|
+
getAllCalls: () => ({ ...calls }),
|
|
180
|
+
clearCalls: () => {
|
|
181
|
+
Object.keys(calls).forEach((key) => {
|
|
182
|
+
calls[key as LogLevel] = [];
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Assertion helpers for testing log output
|
|
190
|
+
*/
|
|
191
|
+
export const logAssertions = {
|
|
192
|
+
/**
|
|
193
|
+
* Check if logs contain a message at a specific level
|
|
194
|
+
*/
|
|
195
|
+
hasLogAtLevel(logs: LogEntry[], level: LogLevel, messagePattern: string | RegExp): boolean {
|
|
196
|
+
return logs.some((log) => {
|
|
197
|
+
if (log.level !== level) return false;
|
|
198
|
+
if (typeof messagePattern === 'string') {
|
|
199
|
+
return log.message.includes(messagePattern);
|
|
200
|
+
}
|
|
201
|
+
return messagePattern.test(log.message);
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check if any log contains specific context properties
|
|
207
|
+
*/
|
|
208
|
+
hasLogWithContext(
|
|
209
|
+
logs: LogEntry[],
|
|
210
|
+
contextMatcher: Partial<LogContext>
|
|
211
|
+
): boolean {
|
|
212
|
+
return logs.some((log) => {
|
|
213
|
+
if (!log.context) return false;
|
|
214
|
+
return Object.entries(contextMatcher).every(
|
|
215
|
+
([key, value]) => log.context![key] === value
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get the most recent log at a specific level
|
|
222
|
+
*/
|
|
223
|
+
getLastLogAtLevel(logs: LogEntry[], level: LogLevel): LogEntry | undefined {
|
|
224
|
+
const filtered = logs.filter((log) => log.level === level);
|
|
225
|
+
return filtered[filtered.length - 1];
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Count logs at a specific level
|
|
230
|
+
*/
|
|
231
|
+
countLogsAtLevel(logs: LogEntry[], level: LogLevel): number {
|
|
232
|
+
return logs.filter((log) => log.level === level).length;
|
|
233
|
+
},
|
|
234
|
+
};
|