@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.
@@ -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
+ };