@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,210 @@
1
+ import type {
2
+ Logger,
3
+ LogContext,
4
+ LogEntry,
5
+ LogLevel,
6
+ BufferedLogger,
7
+ BufferedLoggerOptions,
8
+ FlushDestination,
9
+ } from '../types';
10
+
11
+ /**
12
+ * Creates a buffered logger that wraps an existing logger
13
+ * Logs are sent to the underlying logger AND buffered for later retrieval
14
+ *
15
+ * Key behavior: Child loggers share the parent's buffer
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { createLogger, createBufferedLogger, jsonDestination } from '@sprqvntrs/logger';
20
+ *
21
+ * const baseLogger = createLogger({ serviceName: 'my-service' });
22
+ * const buffered = createBufferedLogger({ logger: baseLogger });
23
+ *
24
+ * buffered.info('Request started', { path: '/api/users' });
25
+ *
26
+ * // Child loggers contribute to parent's buffer
27
+ * const childLogger = buffered.child({ userId: '123' });
28
+ * childLogger.info('User action');
29
+ *
30
+ * // Get all logs (including child logs)
31
+ * const logs = buffered.getBuffer();
32
+ * // logs.length === 2
33
+ *
34
+ * // Flush to JSON and clear
35
+ * const json = await buffered.flush(jsonDestination());
36
+ * // buffered.bufferSize === 0
37
+ * ```
38
+ *
39
+ * @example
40
+ * // With auto-flush on buffer full
41
+ * ```typescript
42
+ * const buffered = createBufferedLogger({
43
+ * logger: baseLogger,
44
+ * maxBufferSize: 100,
45
+ * onBufferFull: async (entries) => {
46
+ * await sendToLogAggregator(entries);
47
+ * },
48
+ * });
49
+ * ```
50
+ */
51
+ export function createBufferedLogger(options: BufferedLoggerOptions): BufferedLogger {
52
+ const { logger, maxBufferSize, onBufferFull } = options;
53
+
54
+ // Shared buffer that child loggers will also write to
55
+ const buffer: LogEntry[] = [];
56
+
57
+ // Track if we're currently flushing to prevent re-entrancy
58
+ let isFlushing = false;
59
+
60
+ /**
61
+ * Adds an entry to the buffer and handles auto-flush if needed
62
+ */
63
+ async function addToBuffer(entry: LogEntry): Promise<void> {
64
+ buffer.push(entry);
65
+
66
+ // Check if we need to auto-flush
67
+ if (maxBufferSize && buffer.length >= maxBufferSize && onBufferFull && !isFlushing) {
68
+ isFlushing = true;
69
+ try {
70
+ const entriesToFlush = [...buffer];
71
+ buffer.length = 0;
72
+ await onBufferFull(entriesToFlush);
73
+ } finally {
74
+ isFlushing = false;
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Creates a log method that logs to the underlying logger and buffers
81
+ */
82
+ function createLogMethod(level: LogLevel, underlyingLogger: Logger) {
83
+ return (message: string, context?: LogContext): void => {
84
+ // Log to underlying logger
85
+ underlyingLogger[level](message, context);
86
+
87
+ // Add to shared buffer (fire and forget for sync interface)
88
+ const entry: LogEntry = {
89
+ level,
90
+ message,
91
+ context,
92
+ timestamp: new Date(),
93
+ };
94
+ void addToBuffer(entry);
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Creates a child buffered logger that shares the parent's buffer
100
+ */
101
+ function createChildBufferedLogger(
102
+ childLogger: Logger,
103
+ bindings: Record<string, unknown>
104
+ ): Logger {
105
+ // Note: Returns Logger, not BufferedLogger, because child should not
106
+ // expose buffer manipulation methods (only parent controls the buffer)
107
+ const child: Logger = {
108
+ trace: (message: string, context?: LogContext): void => {
109
+ childLogger.trace(message, context);
110
+ void addToBuffer({
111
+ level: 'trace',
112
+ message,
113
+ context: { ...bindings, ...context },
114
+ timestamp: new Date(),
115
+ });
116
+ },
117
+ debug: (message: string, context?: LogContext): void => {
118
+ childLogger.debug(message, context);
119
+ void addToBuffer({
120
+ level: 'debug',
121
+ message,
122
+ context: { ...bindings, ...context },
123
+ timestamp: new Date(),
124
+ });
125
+ },
126
+ info: (message: string, context?: LogContext): void => {
127
+ childLogger.info(message, context);
128
+ void addToBuffer({
129
+ level: 'info',
130
+ message,
131
+ context: { ...bindings, ...context },
132
+ timestamp: new Date(),
133
+ });
134
+ },
135
+ warn: (message: string, context?: LogContext): void => {
136
+ childLogger.warn(message, context);
137
+ void addToBuffer({
138
+ level: 'warn',
139
+ message,
140
+ context: { ...bindings, ...context },
141
+ timestamp: new Date(),
142
+ });
143
+ },
144
+ error: (message: string, context?: LogContext): void => {
145
+ childLogger.error(message, context);
146
+ void addToBuffer({
147
+ level: 'error',
148
+ message,
149
+ context: { ...bindings, ...context },
150
+ timestamp: new Date(),
151
+ });
152
+ },
153
+ fatal: (message: string, context?: LogContext): void => {
154
+ childLogger.fatal(message, context);
155
+ void addToBuffer({
156
+ level: 'fatal',
157
+ message,
158
+ context: { ...bindings, ...context },
159
+ timestamp: new Date(),
160
+ });
161
+ },
162
+ child(moreBindings: Record<string, unknown>): Logger {
163
+ const mergedBindings = { ...bindings, ...moreBindings };
164
+ return createChildBufferedLogger(childLogger.child(moreBindings), mergedBindings);
165
+ },
166
+ get pino() {
167
+ return childLogger.pino;
168
+ },
169
+ };
170
+ return child;
171
+ }
172
+
173
+ const bufferedLogger: BufferedLogger = {
174
+ trace: createLogMethod('trace', logger),
175
+ debug: createLogMethod('debug', logger),
176
+ info: createLogMethod('info', logger),
177
+ warn: createLogMethod('warn', logger),
178
+ error: createLogMethod('error', logger),
179
+ fatal: createLogMethod('fatal', logger),
180
+
181
+ child(bindings: Record<string, unknown>): Logger {
182
+ const underlyingChild = logger.child(bindings);
183
+ return createChildBufferedLogger(underlyingChild, bindings);
184
+ },
185
+
186
+ get pino() {
187
+ return logger.pino;
188
+ },
189
+
190
+ getBuffer(): LogEntry[] {
191
+ return [...buffer];
192
+ },
193
+
194
+ clearBuffer(): void {
195
+ buffer.length = 0;
196
+ },
197
+
198
+ async flush<T>(destination: FlushDestination<T>): Promise<T> {
199
+ const entriesToFlush = [...buffer];
200
+ buffer.length = 0;
201
+ return destination.flush(entriesToFlush);
202
+ },
203
+
204
+ get bufferSize(): number {
205
+ return buffer.length;
206
+ },
207
+ };
208
+
209
+ return bufferedLogger;
210
+ }
@@ -0,0 +1,63 @@
1
+ import type { FlushDestination, LogEntry, SerializedLogEntry } from '../types';
2
+
3
+ /**
4
+ * Options for the JSON destination
5
+ */
6
+ export interface JsonDestinationOptions {
7
+ /** Additional metadata to include in the output */
8
+ metadata?: Record<string, unknown>;
9
+ /** Pretty print the JSON output (default: false) */
10
+ pretty?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Serializes a LogEntry to a SerializedLogEntry with ISO timestamp
15
+ */
16
+ function serializeLogEntry(entry: LogEntry): SerializedLogEntry {
17
+ return {
18
+ level: entry.level,
19
+ message: entry.message,
20
+ context: entry.context,
21
+ timestamp: entry.timestamp.toISOString(),
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Creates a flush destination that returns a JSON string
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * const buffered = createBufferedLogger({ logger });
31
+ *
32
+ * buffered.info('Request started');
33
+ * buffered.info('Processing', { step: 1 });
34
+ * buffered.info('Complete');
35
+ *
36
+ * const json = await buffered.flush(jsonDestination());
37
+ * // Returns: {"entries":[...], "count": 3}
38
+ *
39
+ * // With metadata:
40
+ * const json = await buffered.flush(jsonDestination({
41
+ * metadata: { requestId: '123', userId: 'user-1' }
42
+ * }));
43
+ * // Returns: {"entries":[...], "count": 3, "requestId": "123", "userId": "user-1"}
44
+ * ```
45
+ */
46
+ export function jsonDestination(options?: JsonDestinationOptions): FlushDestination<string> {
47
+ return {
48
+ async flush(entries: LogEntry[]): Promise<string> {
49
+ const serializedEntries = entries.map(serializeLogEntry);
50
+
51
+ const output: Record<string, unknown> = {
52
+ entries: serializedEntries,
53
+ count: serializedEntries.length,
54
+ ...options?.metadata,
55
+ };
56
+
57
+ if (options?.pretty) {
58
+ return JSON.stringify(output, null, 2);
59
+ }
60
+ return JSON.stringify(output);
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,208 @@
1
+ import type { Logger as PinoLogger } from 'pino';
2
+ import { createPinoInstance } from './pino-config';
3
+ import { getRequestContext } from '../context/async-context';
4
+ import type {
5
+ Logger,
6
+ LogContext,
7
+ CreateLoggerOptions,
8
+ LoggerConfigureOptions,
9
+ } from '../types';
10
+
11
+ /**
12
+ * Wraps a Pino logger instance with our Logger interface
13
+ */
14
+ function wrapPinoLogger(pinoLogger: PinoLogger): Logger {
15
+ /**
16
+ * Merges context with any request context from AsyncLocalStorage
17
+ */
18
+ function mergeContext(context?: LogContext): LogContext | undefined {
19
+ const requestContext = getRequestContext();
20
+ if (!requestContext && !context) {
21
+ return undefined;
22
+ }
23
+ return {
24
+ ...requestContext,
25
+ ...context,
26
+ };
27
+ }
28
+
29
+ const logger: Logger = {
30
+ trace(message: string, context?: LogContext): void {
31
+ const merged = mergeContext(context);
32
+ if (merged) {
33
+ pinoLogger.trace(merged, message);
34
+ } else {
35
+ pinoLogger.trace(message);
36
+ }
37
+ },
38
+
39
+ debug(message: string, context?: LogContext): void {
40
+ const merged = mergeContext(context);
41
+ if (merged) {
42
+ pinoLogger.debug(merged, message);
43
+ } else {
44
+ pinoLogger.debug(message);
45
+ }
46
+ },
47
+
48
+ info(message: string, context?: LogContext): void {
49
+ const merged = mergeContext(context);
50
+ if (merged) {
51
+ pinoLogger.info(merged, message);
52
+ } else {
53
+ pinoLogger.info(message);
54
+ }
55
+ },
56
+
57
+ warn(message: string, context?: LogContext): void {
58
+ const merged = mergeContext(context);
59
+ if (merged) {
60
+ pinoLogger.warn(merged, message);
61
+ } else {
62
+ pinoLogger.warn(message);
63
+ }
64
+ },
65
+
66
+ error(message: string, context?: LogContext): void {
67
+ const merged = mergeContext(context);
68
+ if (merged) {
69
+ pinoLogger.error(merged, message);
70
+ } else {
71
+ pinoLogger.error(message);
72
+ }
73
+ },
74
+
75
+ fatal(message: string, context?: LogContext): void {
76
+ const merged = mergeContext(context);
77
+ if (merged) {
78
+ pinoLogger.fatal(merged, message);
79
+ } else {
80
+ pinoLogger.fatal(message);
81
+ }
82
+ },
83
+
84
+ child(bindings: Record<string, unknown>): Logger {
85
+ return wrapPinoLogger(pinoLogger.child(bindings));
86
+ },
87
+
88
+ get pino(): PinoLogger {
89
+ return pinoLogger;
90
+ },
91
+ };
92
+
93
+ return logger;
94
+ }
95
+
96
+ /**
97
+ * Creates a new logger instance with the given options
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const logger = createLogger({
102
+ * serviceName: 'my-service',
103
+ * level: 'debug',
104
+ * });
105
+ *
106
+ * logger.info('Server started', { port: 3000 });
107
+ *
108
+ * // Create a child logger with bound context
109
+ * const requestLogger = logger.child({ requestId: '123' });
110
+ * requestLogger.info('Processing request');
111
+ * ```
112
+ */
113
+ export function createLogger(options: CreateLoggerOptions): Logger {
114
+ const pinoLogger = createPinoInstance(options);
115
+ return wrapPinoLogger(pinoLogger);
116
+ }
117
+
118
+ /**
119
+ * Static Logger class for backward compatibility
120
+ * Provides a singleton logger that can be configured once
121
+ *
122
+ * @example
123
+ * ```typescript
124
+ * // Configure once at startup
125
+ * Logger.configure({ serviceName: 'my-service' });
126
+ *
127
+ * // Use anywhere
128
+ * Logger.info('message', { key: 'value' });
129
+ * ```
130
+ */
131
+ export class StaticLogger {
132
+ private static instance: Logger | null = null;
133
+
134
+ /**
135
+ * Configure the static logger
136
+ * Should be called once at application startup
137
+ */
138
+ static configure(options: LoggerConfigureOptions): void {
139
+ StaticLogger.instance = createLogger({
140
+ serviceName: options.serviceName,
141
+ level: options.level,
142
+ pretty: options.pretty,
143
+ base: options.base,
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Get the configured logger instance
149
+ * Creates a default logger if not configured
150
+ */
151
+ private static getLogger(): Logger {
152
+ if (!StaticLogger.instance) {
153
+ // Create a default logger with a warning
154
+ StaticLogger.instance = createLogger({
155
+ serviceName: 'unconfigured',
156
+ });
157
+ StaticLogger.instance.warn(
158
+ 'Logger used before configure() was called. Call Logger.configure() at application startup.'
159
+ );
160
+ }
161
+ return StaticLogger.instance;
162
+ }
163
+
164
+ static trace(message: string, context?: LogContext): void {
165
+ StaticLogger.getLogger().trace(message, context);
166
+ }
167
+
168
+ static debug(message: string, context?: LogContext): void {
169
+ StaticLogger.getLogger().debug(message, context);
170
+ }
171
+
172
+ static info(message: string, context?: LogContext): void {
173
+ StaticLogger.getLogger().info(message, context);
174
+ }
175
+
176
+ static warn(message: string, context?: LogContext): void {
177
+ StaticLogger.getLogger().warn(message, context);
178
+ }
179
+
180
+ static error(message: string, context?: LogContext): void {
181
+ StaticLogger.getLogger().error(message, context);
182
+ }
183
+
184
+ static fatal(message: string, context?: LogContext): void {
185
+ StaticLogger.getLogger().fatal(message, context);
186
+ }
187
+
188
+ /**
189
+ * Create a child logger with bound context
190
+ */
191
+ static child(bindings: Record<string, unknown>): Logger {
192
+ return StaticLogger.getLogger().child(bindings);
193
+ }
194
+
195
+ /**
196
+ * Get the underlying Pino instance
197
+ */
198
+ static get pino(): PinoLogger {
199
+ return StaticLogger.getLogger().pino;
200
+ }
201
+
202
+ /**
203
+ * Reset the static logger (useful for testing)
204
+ */
205
+ static reset(): void {
206
+ StaticLogger.instance = null;
207
+ }
208
+ }
@@ -0,0 +1,92 @@
1
+ import pino from 'pino';
2
+ import type { CreateLoggerOptions } from '../types';
3
+ import { getSerializers } from './serializers';
4
+
5
+ /** Default paths to redact from logs */
6
+ const DEFAULT_REDACT_PATHS = [
7
+ 'password',
8
+ 'token',
9
+ 'authorization',
10
+ 'apiKey',
11
+ 'api_key',
12
+ 'secret',
13
+ 'credential',
14
+ '*.password',
15
+ '*.token',
16
+ '*.authorization',
17
+ '*.apiKey',
18
+ '*.api_key',
19
+ '*.secret',
20
+ '*.credential',
21
+ 'headers.authorization',
22
+ 'headers.cookie',
23
+ ];
24
+
25
+ /**
26
+ * Gets the log level from options or environment
27
+ */
28
+ function getLogLevel(options: CreateLoggerOptions): string {
29
+ return options.level ?? process.env['LOG_LEVEL'] ?? 'info';
30
+ }
31
+
32
+ /**
33
+ * Creates the transport configuration for pretty printing
34
+ */
35
+ function createPrettyTransport(): pino.TransportSingleOptions {
36
+ return {
37
+ target: 'pino-pretty',
38
+ options: {
39
+ colorize: true,
40
+ translateTime: 'UTC:yyyy-mm-dd HH:MM:ss.l',
41
+ ignore: 'pid,hostname',
42
+ messageFormat: '{msg}',
43
+ },
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Creates a Pino logger configuration from options
49
+ */
50
+ export function createPinoConfig(options: CreateLoggerOptions): pino.LoggerOptions {
51
+ const shouldPrettyPrint = options.pretty ?? false;
52
+ const logLevel = getLogLevel(options);
53
+ const redactPaths = [...DEFAULT_REDACT_PATHS, ...(options.redactPaths ?? [])];
54
+
55
+ const config: pino.LoggerOptions = {
56
+ level: logLevel,
57
+ base: {
58
+ service: options.serviceName,
59
+ version: options.version ?? process.env['npm_package_version'],
60
+ env: process.env['NODE_ENV'],
61
+ ...options.base,
62
+ },
63
+ timestamp: options.timestamp !== false ? pino.stdTimeFunctions.isoTime : false,
64
+ formatters: {
65
+ level: (label) => ({ level: label }),
66
+ bindings: (bindings) => {
67
+ // In production, keep hostname and pid; in dev, they're ignored by pino-pretty
68
+ return bindings;
69
+ },
70
+ },
71
+ redact: {
72
+ paths: redactPaths,
73
+ censor: '[REDACTED]',
74
+ },
75
+ serializers: getSerializers(),
76
+ };
77
+
78
+ // Only add transport in development mode with pretty printing
79
+ if (shouldPrettyPrint) {
80
+ config.transport = createPrettyTransport();
81
+ }
82
+
83
+ return config;
84
+ }
85
+
86
+ /**
87
+ * Creates a configured Pino logger instance
88
+ */
89
+ export function createPinoInstance(options: CreateLoggerOptions): pino.Logger {
90
+ const config = createPinoConfig(options);
91
+ return pino(config);
92
+ }
@@ -0,0 +1,81 @@
1
+ import pino from 'pino';
2
+
3
+ /**
4
+ * Standard error serializer that handles Error objects
5
+ * and extracts useful information including cause chain
6
+ */
7
+ export function errorSerializer(error: unknown): Record<string, unknown> | unknown {
8
+ if (error instanceof Error) {
9
+ return {
10
+ type: error.constructor.name,
11
+ message: error.message,
12
+ stack: error.stack,
13
+ ...(error.cause !== undefined && { cause: errorSerializer(error.cause) }),
14
+ // Include any additional properties on the error
15
+ ...Object.fromEntries(
16
+ Object.entries(error).filter(
17
+ ([key]) => !['message', 'stack', 'cause'].includes(key)
18
+ )
19
+ ),
20
+ };
21
+ }
22
+ return error;
23
+ }
24
+
25
+ /**
26
+ * Request serializer for HTTP requests
27
+ * Extracts commonly needed fields while excluding sensitive data
28
+ */
29
+ export function requestSerializer(req: {
30
+ id?: string;
31
+ method?: string;
32
+ url?: string;
33
+ headers?: Record<string, string>;
34
+ remoteAddress?: string;
35
+ }): Record<string, unknown> {
36
+ return {
37
+ id: req.id,
38
+ method: req.method,
39
+ url: req.url,
40
+ // Only include non-sensitive headers
41
+ headers: req.headers
42
+ ? {
43
+ 'user-agent': req.headers['user-agent'],
44
+ 'content-type': req.headers['content-type'],
45
+ 'content-length': req.headers['content-length'],
46
+ host: req.headers['host'],
47
+ }
48
+ : undefined,
49
+ remoteAddress: req.remoteAddress,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Response serializer for HTTP responses
55
+ */
56
+ export function responseSerializer(res: {
57
+ statusCode?: number;
58
+ headers?: Record<string, string>;
59
+ }): Record<string, unknown> {
60
+ return {
61
+ statusCode: res.statusCode,
62
+ headers: res.headers
63
+ ? {
64
+ 'content-type': res.headers['content-type'],
65
+ 'content-length': res.headers['content-length'],
66
+ }
67
+ : undefined,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Get all custom serializers
73
+ */
74
+ export function getSerializers(): pino.LoggerOptions['serializers'] {
75
+ return {
76
+ err: pino.stdSerializers.err,
77
+ error: errorSerializer,
78
+ req: requestSerializer,
79
+ res: responseSerializer,
80
+ };
81
+ }