@outfitter/logging 0.1.0-rc.1

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/README.md ADDED
@@ -0,0 +1,338 @@
1
+ # @outfitter/logging
2
+
3
+ Structured logging via logtape with automatic sensitive data redaction. Provides consistent log formatting across CLI, MCP, and server contexts.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @outfitter/logging
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import {
15
+ createLogger,
16
+ createConsoleSink,
17
+ configureRedaction,
18
+ } from "@outfitter/logging";
19
+
20
+ // Configure global redaction (optional - defaults already cover common sensitive keys)
21
+ configureRedaction({
22
+ keys: ["apiKey", "accessToken"],
23
+ patterns: [/sk-[a-zA-Z0-9]+/g],
24
+ });
25
+
26
+ // Create a logger
27
+ const logger = createLogger({
28
+ name: "my-service",
29
+ level: "debug",
30
+ sinks: [createConsoleSink()],
31
+ redaction: { enabled: true },
32
+ });
33
+
34
+ // Log with metadata
35
+ logger.info("Request received", {
36
+ path: "/api/users",
37
+ apiKey: "secret-key-123", // Will be redacted to "[REDACTED]"
38
+ });
39
+ ```
40
+
41
+ ## Log Levels
42
+
43
+ | Level | Priority | Use For |
44
+ | -------- | -------- | ------------------------------------------ |
45
+ | `trace` | 0 | Very detailed debugging (loops, internals) |
46
+ | `debug` | 1 | Development debugging |
47
+ | `info` | 2 | Normal operations |
48
+ | `warn` | 3 | Unexpected but handled situations |
49
+ | `error` | 4 | Failures requiring attention |
50
+ | `fatal` | 5 | Unrecoverable failures |
51
+ | `silent` | 6 | Disable all logging |
52
+
53
+ Messages are filtered by minimum level. Setting `level: "warn"` filters out `trace`, `debug`, and `info`.
54
+
55
+ ```typescript
56
+ const logger = createLogger({
57
+ name: "app",
58
+ level: "warn", // Only warn, error, fatal will be logged
59
+ sinks: [createConsoleSink()],
60
+ });
61
+
62
+ logger.debug("Filtered out");
63
+ logger.warn("This appears");
64
+ ```
65
+
66
+ ### Changing Level at Runtime
67
+
68
+ ```typescript
69
+ logger.setLevel("debug"); // Enable debug logging
70
+ logger.setLevel("silent"); // Disable all logging
71
+ ```
72
+
73
+ ## Redaction
74
+
75
+ Automatic redaction protects sensitive data from appearing in logs.
76
+
77
+ ### Default Sensitive Keys
78
+
79
+ These keys are redacted by default (case-insensitive matching):
80
+
81
+ - `password`
82
+ - `secret`
83
+ - `token`
84
+ - `apikey`
85
+
86
+ ### Custom Redaction Patterns
87
+
88
+ ```typescript
89
+ configureRedaction({
90
+ patterns: [
91
+ /Bearer [a-zA-Z0-9._-]+/g, // Bearer tokens
92
+ /sk-[a-zA-Z0-9]{20,}/g, // OpenAI keys
93
+ /ghp_[a-zA-Z0-9]{36}/g, // GitHub PATs
94
+ ],
95
+ keys: ["credentials", "privateKey"],
96
+ });
97
+ ```
98
+
99
+ ### Per-Logger Redaction
100
+
101
+ ```typescript
102
+ const logger = createLogger({
103
+ name: "auth",
104
+ redaction: {
105
+ enabled: true,
106
+ patterns: [/custom-secret-\d+/g],
107
+ keys: ["myCustomKey"],
108
+ replacement: "***", // Custom replacement (default: "[REDACTED]")
109
+ },
110
+ });
111
+ ```
112
+
113
+ ### Nested Object Redaction
114
+
115
+ Redaction is recursive and applies to nested objects:
116
+
117
+ ```typescript
118
+ logger.info("Config loaded", {
119
+ database: {
120
+ host: "localhost",
121
+ password: "super-secret", // Redacted
122
+ },
123
+ api: {
124
+ url: "https://api.example.com",
125
+ token: "jwt-token", // Redacted
126
+ },
127
+ });
128
+ // Output: { database: { host: "localhost", password: "[REDACTED]" }, ... }
129
+ ```
130
+
131
+ ## Child Loggers
132
+
133
+ Create scoped loggers that inherit parent configuration and merge context:
134
+
135
+ ```typescript
136
+ const parent = createLogger({
137
+ name: "app",
138
+ context: { service: "api" },
139
+ sinks: [createConsoleSink()],
140
+ redaction: { enabled: true },
141
+ });
142
+
143
+ const child = createChildLogger(parent, { handler: "getUser" });
144
+
145
+ child.info("Processing request");
146
+ // Output includes merged context: { service: "api", handler: "getUser" }
147
+ ```
148
+
149
+ Child loggers:
150
+
151
+ - Inherit parent's sinks, level, and redaction config
152
+ - Merge context (child overrides parent for conflicting keys)
153
+ - Share the same `setLevel()` and `addSink()` behavior
154
+
155
+ ## Formatters
156
+
157
+ ### JSON Formatter
158
+
159
+ Machine-readable output for log aggregation:
160
+
161
+ ```typescript
162
+ import { createJsonFormatter } from "@outfitter/logging";
163
+
164
+ const formatter = createJsonFormatter();
165
+ // Output: {"timestamp":1705936800000,"level":"info","category":"app","message":"Hello","userId":"123"}
166
+ ```
167
+
168
+ ### Pretty Formatter
169
+
170
+ Human-readable output with optional ANSI colors:
171
+
172
+ ```typescript
173
+ import { createPrettyFormatter } from "@outfitter/logging";
174
+
175
+ const formatter = createPrettyFormatter({ colors: true, timestamp: true });
176
+ // Output: 2024-01-22T12:00:00.000Z [INFO] app: Hello {"userId":"123"}
177
+ ```
178
+
179
+ ## Sinks
180
+
181
+ ### Console Sink
182
+
183
+ Routes logs to stdout/stderr based on level:
184
+
185
+ - `trace`, `debug`, `info` -> stdout
186
+ - `warn`, `error`, `fatal` -> stderr
187
+
188
+ ```typescript
189
+ import { createConsoleSink } from "@outfitter/logging";
190
+
191
+ const logger = createLogger({
192
+ name: "app",
193
+ sinks: [createConsoleSink()],
194
+ });
195
+ ```
196
+
197
+ ### File Sink
198
+
199
+ Buffered writes to a file path:
200
+
201
+ ```typescript
202
+ import { createFileSink, flush } from "@outfitter/logging";
203
+
204
+ const logger = createLogger({
205
+ name: "app",
206
+ sinks: [createFileSink({ path: "/var/log/app.log" })],
207
+ });
208
+
209
+ logger.info("Application started");
210
+
211
+ // Call flush() before exit to ensure all logs are written
212
+ await flush();
213
+ ```
214
+
215
+ ### Custom Sinks
216
+
217
+ Implement the `Sink` interface for custom destinations:
218
+
219
+ ```typescript
220
+ import type { Sink, LogRecord, Formatter } from "@outfitter/logging";
221
+
222
+ const customSink: Sink = {
223
+ formatter: createJsonFormatter(), // Optional
224
+ write(record: LogRecord, formatted?: string): void {
225
+ // Send to your destination
226
+ sendToRemote(formatted ?? JSON.stringify(record));
227
+ },
228
+ async flush(): Promise<void> {
229
+ // Optional: ensure pending writes complete
230
+ await flushPendingWrites();
231
+ },
232
+ };
233
+ ```
234
+
235
+ ### Multiple Sinks
236
+
237
+ Logs can be sent to multiple destinations:
238
+
239
+ ```typescript
240
+ const logger = createLogger({
241
+ name: "app",
242
+ sinks: [
243
+ createConsoleSink(),
244
+ createFileSink({ path: "/var/log/app.log" }),
245
+ customRemoteSink,
246
+ ],
247
+ });
248
+ ```
249
+
250
+ ## Structured Metadata
251
+
252
+ ### Basic Metadata
253
+
254
+ ```typescript
255
+ logger.info("User logged in", {
256
+ userId: "u123",
257
+ email: "user@example.com",
258
+ });
259
+ ```
260
+
261
+ ### Error Serialization
262
+
263
+ Error objects are automatically serialized with name, message, and stack:
264
+
265
+ ```typescript
266
+ try {
267
+ await riskyOperation();
268
+ } catch (error) {
269
+ logger.error("Operation failed", { error });
270
+ // error is serialized as: { name: "Error", message: "...", stack: "..." }
271
+ }
272
+ ```
273
+
274
+ ### Context Inheritance
275
+
276
+ Logger context is merged with per-call metadata:
277
+
278
+ ```typescript
279
+ const logger = createLogger({
280
+ name: "api",
281
+ context: { requestId: "abc123" },
282
+ sinks: [createConsoleSink()],
283
+ });
284
+
285
+ logger.info("Processing", { step: 1 });
286
+ // Metadata: { requestId: "abc123", step: 1 }
287
+ ```
288
+
289
+ ## Flushing
290
+
291
+ Call `flush()` before process exit to ensure buffered logs are written:
292
+
293
+ ```typescript
294
+ import { flush } from "@outfitter/logging";
295
+
296
+ process.on("beforeExit", async () => {
297
+ await flush();
298
+ });
299
+
300
+ // Or before explicit exit
301
+ logger.info("Shutting down");
302
+ await flush();
303
+ process.exit(0);
304
+ ```
305
+
306
+ ## API Reference
307
+
308
+ ### Functions
309
+
310
+ | Function | Description |
311
+ | ----------------------- | --------------------------------------------------- |
312
+ | `createLogger` | Create a configured logger instance |
313
+ | `createChildLogger` | Create a child logger with merged context |
314
+ | `configureRedaction` | Configure global redaction patterns and keys |
315
+ | `flush` | Flush all pending log writes across all sinks |
316
+ | `createJsonFormatter` | Create a JSON formatter for structured output |
317
+ | `createPrettyFormatter` | Create a human-readable formatter with colors |
318
+ | `createConsoleSink` | Create a console sink (stdout/stderr routing) |
319
+ | `createFileSink` | Create a file sink with buffered writes |
320
+
321
+ ### Types
322
+
323
+ | Type | Description |
324
+ | ------------------------ | ----------------------------------------------- |
325
+ | `LogLevel` | Union of log level strings |
326
+ | `LogRecord` | Structured log record with timestamp/metadata |
327
+ | `LoggerConfig` | Configuration options for `createLogger` |
328
+ | `LoggerInstance` | Logger interface with level methods |
329
+ | `RedactionConfig` | Per-logger redaction configuration |
330
+ | `GlobalRedactionConfig` | Global redaction patterns and keys |
331
+ | `Formatter` | Interface for log record formatting |
332
+ | `Sink` | Interface for log output destinations |
333
+ | `PrettyFormatterOptions` | Options for human-readable formatter |
334
+ | `FileSinkOptions` | Options for file sink configuration |
335
+
336
+ ## License
337
+
338
+ MIT
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Log levels supported by the logger, ordered from lowest to highest severity.
3
+ *
4
+ * Level priority (lowest to highest): trace (0) < debug (1) < info (2) < warn (3) < error (4) < fatal (5)
5
+ *
6
+ * The special level "silent" (6) disables all logging when set as the minimum level.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * const level: LogLevel = "info";
11
+ *
12
+ * // Set minimum level to filter logs
13
+ * const logger = createLogger({ name: "app", level: "warn" });
14
+ * logger.debug("Filtered out"); // Not logged
15
+ * logger.warn("Logged"); // Logged
16
+ * ```
17
+ */
18
+ type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "silent";
19
+ /**
20
+ * A structured log record containing all information about a log event.
21
+ *
22
+ * Log records are passed to formatters and sinks for processing. They contain
23
+ * the timestamp, level, category (logger name), message, and optional metadata.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * const record: LogRecord = {
28
+ * timestamp: Date.now(),
29
+ * level: "info",
30
+ * category: "my-service",
31
+ * message: "Request processed",
32
+ * metadata: { duration: 150, status: 200 },
33
+ * };
34
+ * ```
35
+ */
36
+ interface LogRecord {
37
+ /** Unix timestamp in milliseconds when the log was created */
38
+ timestamp: number;
39
+ /** Severity level of the log (excludes "silent" which is only for filtering) */
40
+ level: Exclude<LogLevel, "silent">;
41
+ /** Logger category/name identifying the source (e.g., "my-service", "api") */
42
+ category: string;
43
+ /** Human-readable log message describing the event */
44
+ message: string;
45
+ /** Optional structured metadata attached to the log for additional context */
46
+ metadata?: Record<string, unknown>;
47
+ }
48
+ /**
49
+ * Formatter interface for converting log records to strings.
50
+ *
51
+ * Formatters are responsible for serializing log records into output strings.
52
+ * Built-in formatters include JSON (for machine parsing) and pretty (for humans).
53
+ *
54
+ * @example
55
+ * ```typescript
56
+ * const customFormatter: Formatter = {
57
+ * format(record: LogRecord): string {
58
+ * return `[${record.level.toUpperCase()}] ${record.category}: ${record.message}`;
59
+ * },
60
+ * };
61
+ *
62
+ * const sink: Sink = {
63
+ * formatter: customFormatter,
64
+ * write(record, formatted) {
65
+ * console.log(formatted); // "[INFO] app: Hello world"
66
+ * },
67
+ * };
68
+ * ```
69
+ */
70
+ interface Formatter {
71
+ /**
72
+ * Format a log record into a string representation.
73
+ *
74
+ * @param record - The log record to format
75
+ * @returns Formatted string representation of the log record
76
+ */
77
+ format(record: LogRecord): string;
78
+ }
79
+ /**
80
+ * Sink interface for outputting log records to various destinations.
81
+ *
82
+ * Sinks receive log records and write them to their destination (console, file,
83
+ * remote service, etc.). They can optionally have a formatter to convert records
84
+ * to strings, and a flush method for buffered writes.
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * const customSink: Sink = {
89
+ * formatter: createJsonFormatter(),
90
+ * write(record: LogRecord, formatted?: string): void {
91
+ * const output = formatted ?? JSON.stringify(record);
92
+ * sendToRemoteService(output);
93
+ * },
94
+ * async flush(): Promise<void> {
95
+ * await flushPendingRequests();
96
+ * },
97
+ * };
98
+ * ```
99
+ */
100
+ interface Sink {
101
+ /**
102
+ * Write a log record to the sink's destination.
103
+ *
104
+ * @param record - The log record to write
105
+ * @param formatted - Optional pre-formatted string from the sink's formatter
106
+ */
107
+ write(record: LogRecord, formatted?: string): void;
108
+ /** Optional formatter specific to this sink for converting records to strings */
109
+ formatter?: Formatter;
110
+ /**
111
+ * Optional async flush to ensure all pending/buffered writes complete.
112
+ * Called by the global `flush()` function before process exit.
113
+ *
114
+ * @returns Promise that resolves when all pending writes are complete
115
+ */
116
+ flush?(): Promise<void>;
117
+ }
118
+ /**
119
+ * Redaction configuration for sensitive data scrubbing.
120
+ *
121
+ * Redaction automatically replaces sensitive values in log metadata to prevent
122
+ * accidental exposure of secrets, tokens, and credentials. Default sensitive keys
123
+ * (password, secret, token, apikey) are always redacted when enabled.
124
+ *
125
+ * @example
126
+ * ```typescript
127
+ * const logger = createLogger({
128
+ * name: "auth",
129
+ * redaction: {
130
+ * enabled: true,
131
+ * patterns: [/Bearer [a-zA-Z0-9._-]+/g], // Redact Bearer tokens in strings
132
+ * keys: ["privateKey", "credentials"], // Additional keys to redact
133
+ * replacement: "***", // Custom replacement text
134
+ * },
135
+ * });
136
+ * ```
137
+ */
138
+ interface RedactionConfig {
139
+ /**
140
+ * Enable or disable redaction for this logger.
141
+ * @defaultValue true (when RedactionConfig is provided)
142
+ */
143
+ enabled?: boolean;
144
+ /** Additional regex patterns to match and redact within string values */
145
+ patterns?: RegExp[];
146
+ /** Additional key names whose values should be fully redacted (case-insensitive) */
147
+ keys?: string[];
148
+ /**
149
+ * Replacement string for redacted values.
150
+ * @defaultValue "[REDACTED]"
151
+ */
152
+ replacement?: string;
153
+ }
154
+ /**
155
+ * Configuration options for creating a logger.
156
+ *
157
+ * Defines the logger's name, minimum level, context, sinks, and redaction settings.
158
+ *
159
+ * @example
160
+ * ```typescript
161
+ * const config: LoggerConfig = {
162
+ * name: "my-service",
163
+ * level: "debug",
164
+ * context: { service: "api", version: "1.0.0" },
165
+ * sinks: [createConsoleSink(), createFileSink({ path: "/var/log/app.log" })],
166
+ * redaction: { enabled: true },
167
+ * };
168
+ *
169
+ * const logger = createLogger(config);
170
+ * ```
171
+ */
172
+ interface LoggerConfig {
173
+ /** Logger name used as the category in log records */
174
+ name: string;
175
+ /**
176
+ * Minimum log level to output. Messages below this level are filtered.
177
+ * @defaultValue "info"
178
+ */
179
+ level?: LogLevel;
180
+ /** Initial context metadata attached to all log records from this logger */
181
+ context?: Record<string, unknown>;
182
+ /** Array of sinks to output log records to */
183
+ sinks?: Sink[];
184
+ /** Redaction configuration for sensitive data scrubbing */
185
+ redaction?: RedactionConfig;
186
+ }
187
+ /**
188
+ * Logger instance with methods for each log level.
189
+ *
190
+ * Provides methods for logging at each severity level, plus utilities for
191
+ * runtime configuration changes and context inspection.
192
+ *
193
+ * @example
194
+ * ```typescript
195
+ * const logger = createLogger({ name: "app", sinks: [createConsoleSink()] });
196
+ *
197
+ * logger.info("Server started", { port: 3000 });
198
+ * logger.error("Failed to connect", { error: new Error("timeout") });
199
+ *
200
+ * logger.setLevel("debug"); // Enable debug logging at runtime
201
+ * logger.debug("Debug info", { details: "..." });
202
+ * ```
203
+ */
204
+ interface LoggerInstance {
205
+ /**
206
+ * Log at trace level (most verbose, for detailed debugging).
207
+ * @param message - Human-readable log message
208
+ * @param metadata - Optional structured metadata
209
+ */
210
+ trace(message: string, metadata?: Record<string, unknown>): void;
211
+ /**
212
+ * Log at debug level (development debugging).
213
+ * @param message - Human-readable log message
214
+ * @param metadata - Optional structured metadata
215
+ */
216
+ debug(message: string, metadata?: Record<string, unknown>): void;
217
+ /**
218
+ * Log at info level (normal operations).
219
+ * @param message - Human-readable log message
220
+ * @param metadata - Optional structured metadata
221
+ */
222
+ info(message: string, metadata?: Record<string, unknown>): void;
223
+ /**
224
+ * Log at warn level (unexpected but handled situations).
225
+ * @param message - Human-readable log message
226
+ * @param metadata - Optional structured metadata
227
+ */
228
+ warn(message: string, metadata?: Record<string, unknown>): void;
229
+ /**
230
+ * Log at error level (failures requiring attention).
231
+ * @param message - Human-readable log message
232
+ * @param metadata - Optional structured metadata
233
+ */
234
+ error(message: string, metadata?: Record<string, unknown>): void;
235
+ /**
236
+ * Log at fatal level (unrecoverable failures).
237
+ * @param message - Human-readable log message
238
+ * @param metadata - Optional structured metadata
239
+ */
240
+ fatal(message: string, metadata?: Record<string, unknown>): void;
241
+ /**
242
+ * Get the current context metadata attached to this logger.
243
+ * @returns Copy of the logger's context object
244
+ */
245
+ getContext(): Record<string, unknown>;
246
+ /**
247
+ * Set the minimum log level at runtime.
248
+ * @param level - New minimum level (messages below this are filtered)
249
+ */
250
+ setLevel(level: LogLevel): void;
251
+ /**
252
+ * Add a sink at runtime.
253
+ * @param sink - Sink to add to this logger's output destinations
254
+ */
255
+ addSink(sink: Sink): void;
256
+ /**
257
+ * Creates a child logger with additional context.
258
+ *
259
+ * Context from the child is merged with the parent's context,
260
+ * with child context taking precedence for duplicate keys.
261
+ * Child loggers are composable (can create nested children).
262
+ *
263
+ * @param context - Additional context to include in all log messages
264
+ * @returns A new LoggerInstance with the merged context
265
+ *
266
+ * @example
267
+ * ```typescript
268
+ * const requestLogger = logger.child({ requestId: "abc123" });
269
+ * requestLogger.info("Processing request"); // includes requestId
270
+ *
271
+ * const opLogger = requestLogger.child({ operation: "create" });
272
+ * opLogger.debug("Starting"); // includes requestId + operation
273
+ * ```
274
+ */
275
+ child(context: Record<string, unknown>): LoggerInstance;
276
+ }
277
+ /**
278
+ * Options for the pretty (human-readable) formatter.
279
+ *
280
+ * Controls ANSI color output and timestamp inclusion for terminal display.
281
+ *
282
+ * @example
283
+ * ```typescript
284
+ * // Colorized with timestamps (default)
285
+ * const formatter = createPrettyFormatter({ colors: true, timestamp: true });
286
+ *
287
+ * // Plain text without timestamps (for piping)
288
+ * const plainFormatter = createPrettyFormatter({ colors: false, timestamp: false });
289
+ * ```
290
+ */
291
+ interface PrettyFormatterOptions {
292
+ /**
293
+ * Enable ANSI colors in output. Colors are applied per log level:
294
+ * trace (dim), debug (cyan), info (green), warn (yellow), error (red), fatal (magenta).
295
+ * @defaultValue false
296
+ */
297
+ colors?: boolean;
298
+ /**
299
+ * Include ISO 8601 timestamp in output.
300
+ * @defaultValue true
301
+ */
302
+ timestamp?: boolean;
303
+ }
304
+ /**
305
+ * Options for the file sink.
306
+ *
307
+ * Configures the file path and append behavior for file-based logging.
308
+ * File sink uses buffered writes - call `flush()` before exit to ensure
309
+ * all logs are written.
310
+ *
311
+ * @example
312
+ * ```typescript
313
+ * const sink = createFileSink({
314
+ * path: "/var/log/app.log",
315
+ * append: true, // Append to existing file (default)
316
+ * });
317
+ *
318
+ * // For fresh logs on each run:
319
+ * const freshSink = createFileSink({
320
+ * path: "/tmp/session.log",
321
+ * append: false, // Truncate file on start
322
+ * });
323
+ * ```
324
+ */
325
+ interface FileSinkOptions {
326
+ /** Absolute path to the log file */
327
+ path: string;
328
+ /**
329
+ * Append to existing file or truncate on start.
330
+ * @defaultValue true
331
+ */
332
+ append?: boolean;
333
+ }
334
+ /**
335
+ * Global redaction configuration options.
336
+ *
337
+ * Patterns and keys configured globally apply to all loggers that have
338
+ * redaction enabled. Use `configureRedaction()` to add global patterns.
339
+ *
340
+ * @example
341
+ * ```typescript
342
+ * // Configure global patterns that apply to all loggers
343
+ * configureRedaction({
344
+ * patterns: [
345
+ * /ghp_[a-zA-Z0-9]{36}/g, // GitHub PATs
346
+ * /sk-[a-zA-Z0-9]{20,}/g, // OpenAI keys
347
+ * ],
348
+ * keys: ["privateKey", "credentials"],
349
+ * });
350
+ * ```
351
+ */
352
+ interface GlobalRedactionConfig {
353
+ /** Regex patterns to match and redact within string values (applied globally) */
354
+ patterns?: RegExp[];
355
+ /** Key names whose values should be fully redacted (case-insensitive, applied globally) */
356
+ keys?: string[];
357
+ }
358
+ /**
359
+ * Default patterns for redacting secrets from log messages.
360
+ * Applied to message strings and stringified metadata values.
361
+ *
362
+ * Patterns include:
363
+ * - Bearer tokens (Authorization headers)
364
+ * - API key patterns (api_key=xxx, apikey: xxx)
365
+ * - GitHub Personal Access Tokens (ghp_xxx)
366
+ * - GitHub OAuth tokens (gho_xxx)
367
+ * - GitHub App tokens (ghs_xxx)
368
+ * - GitHub refresh tokens (ghr_xxx)
369
+ * - PEM-encoded private keys
370
+ *
371
+ * @example
372
+ * ```typescript
373
+ * import { DEFAULT_PATTERNS } from "@outfitter/logging";
374
+ *
375
+ * // Use with custom logger configuration
376
+ * const logger = createLogger({
377
+ * name: "app",
378
+ * redaction: {
379
+ * enabled: true,
380
+ * patterns: [...DEFAULT_PATTERNS, /my-custom-secret-\w+/gi],
381
+ * },
382
+ * });
383
+ * ```
384
+ */
385
+ declare const DEFAULT_PATTERNS: RegExp[];
386
+ /**
387
+ * Create a configured logger instance.
388
+ *
389
+ * @param config - Logger configuration options
390
+ * @returns Configured LoggerInstance
391
+ *
392
+ * @example
393
+ * ```typescript
394
+ * const logger = createLogger({
395
+ * name: "my-service",
396
+ * level: "debug",
397
+ * redaction: { enabled: true },
398
+ * });
399
+ *
400
+ * logger.info("Server started", { port: 3000 });
401
+ * ```
402
+ */
403
+ declare function createLogger(config: LoggerConfig): LoggerInstance;
404
+ /**
405
+ * Create a child logger with merged context from a parent logger.
406
+ *
407
+ * @param parent - Parent logger instance
408
+ * @param context - Additional context to merge with parent context
409
+ * @returns Child LoggerInstance with merged context
410
+ *
411
+ * @example
412
+ * ```typescript
413
+ * const parent = createLogger({ name: "app", context: { service: "api" } });
414
+ * const child = createChildLogger(parent, { handler: "getUser" });
415
+ * // child has context: { service: "api", handler: "getUser" }
416
+ * ```
417
+ */
418
+ declare function createChildLogger(parent: LoggerInstance, context: Record<string, unknown>): LoggerInstance;
419
+ /**
420
+ * Create a JSON formatter for structured log output.
421
+ *
422
+ * @returns Formatter that outputs JSON strings
423
+ *
424
+ * @example
425
+ * ```typescript
426
+ * const formatter = createJsonFormatter();
427
+ * const output = formatter.format(record);
428
+ * // {"timestamp":1705936800000,"level":"info","message":"Hello",...}
429
+ * ```
430
+ */
431
+ declare function createJsonFormatter(): Formatter;
432
+ /**
433
+ * Create a human-readable formatter with optional colors.
434
+ *
435
+ * @param options - Formatter options
436
+ * @returns Formatter that outputs human-readable strings
437
+ *
438
+ * @example
439
+ * ```typescript
440
+ * const formatter = createPrettyFormatter({ colors: true });
441
+ * const output = formatter.format(record);
442
+ * // 2024-01-22T12:00:00.000Z [INFO] my-service: Hello world
443
+ * ```
444
+ */
445
+ declare function createPrettyFormatter(options?: PrettyFormatterOptions): Formatter;
446
+ /**
447
+ * Create a console sink that writes to stdout/stderr.
448
+ * Info and below go to stdout, warn and above go to stderr.
449
+ *
450
+ * @returns Sink configured for console output
451
+ *
452
+ * @example
453
+ * ```typescript
454
+ * const logger = createLogger({
455
+ * name: "app",
456
+ * sinks: [createConsoleSink()],
457
+ * });
458
+ * ```
459
+ */
460
+ declare function createConsoleSink(): Sink;
461
+ /**
462
+ * Create a file sink that writes to a specified file path.
463
+ *
464
+ * @param options - File sink options
465
+ * @returns Sink configured for file output
466
+ *
467
+ * @example
468
+ * ```typescript
469
+ * const logger = createLogger({
470
+ * name: "app",
471
+ * sinks: [createFileSink({ path: "/var/log/app.log" })],
472
+ * });
473
+ * ```
474
+ */
475
+ declare function createFileSink(options: FileSinkOptions): Sink;
476
+ /**
477
+ * Configure global redaction patterns and keys that apply to all loggers.
478
+ *
479
+ * @param config - Global redaction configuration
480
+ *
481
+ * @example
482
+ * ```typescript
483
+ * configureRedaction({
484
+ * patterns: [/custom-secret-\d+/g],
485
+ * keys: ["myCustomKey"],
486
+ * });
487
+ * ```
488
+ */
489
+ declare function configureRedaction(config: GlobalRedactionConfig): void;
490
+ /**
491
+ * Flush all pending log writes across all sinks.
492
+ * Call this before process exit to ensure all logs are written.
493
+ *
494
+ * @returns Promise that resolves when all sinks have flushed
495
+ *
496
+ * @example
497
+ * ```typescript
498
+ * logger.info("Shutting down");
499
+ * await flush();
500
+ * process.exit(0);
501
+ * ```
502
+ */
503
+ declare function flush(): Promise<void>;
504
+ export { flush, createPrettyFormatter, createLogger, createJsonFormatter, createFileSink, createConsoleSink, createChildLogger, configureRedaction, Sink, RedactionConfig, PrettyFormatterOptions, LoggerInstance, LoggerConfig, LogRecord, LogLevel, GlobalRedactionConfig, Formatter, FileSinkOptions, DEFAULT_PATTERNS };
package/dist/index.js ADDED
@@ -0,0 +1,371 @@
1
+ // src/index.ts
2
+ import { writeFileSync } from "node:fs";
3
+ var LEVEL_PRIORITY = {
4
+ trace: 0,
5
+ debug: 1,
6
+ info: 2,
7
+ warn: 3,
8
+ error: 4,
9
+ fatal: 5,
10
+ silent: 6
11
+ };
12
+ var DEFAULT_SENSITIVE_KEYS = ["password", "secret", "token", "apikey"];
13
+ var DEFAULT_PATTERNS = [
14
+ /Bearer\s+[A-Za-z0-9\-_.~+/]+=*/gi,
15
+ /(?:api[_-]?key|apikey)[=:]\s*["']?[A-Za-z0-9\-_.]+["']?/gi,
16
+ /ghp_[A-Za-z0-9]{36}/g,
17
+ /gho_[A-Za-z0-9]{36}/g,
18
+ /ghs_[A-Za-z0-9]{36}/g,
19
+ /ghr_[A-Za-z0-9]{36}/g,
20
+ /-----BEGIN[\s\S]*?PRIVATE\s*KEY-----[\s\S]*?-----END[\s\S]*?PRIVATE\s*KEY-----/gi
21
+ ];
22
+ var globalRedactionConfig = {
23
+ patterns: [],
24
+ keys: []
25
+ };
26
+ var registeredSinks = new Set;
27
+ function shouldLog(level, minLevel) {
28
+ if (minLevel === "silent")
29
+ return false;
30
+ return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[minLevel];
31
+ }
32
+ function isRedactedKey(key, additionalKeys) {
33
+ const lowerKey = key.toLowerCase();
34
+ const allKeys = [...DEFAULT_SENSITIVE_KEYS, ...additionalKeys];
35
+ return allKeys.some((k) => lowerKey === k.toLowerCase());
36
+ }
37
+ function applyPatterns(value, patterns, replacement) {
38
+ let result = value;
39
+ for (const pattern of patterns) {
40
+ pattern.lastIndex = 0;
41
+ result = result.replace(pattern, replacement);
42
+ }
43
+ return result;
44
+ }
45
+ function redactValue(value, keys, patterns, replacement, currentKey) {
46
+ if (currentKey !== undefined && isRedactedKey(currentKey, keys)) {
47
+ return replacement;
48
+ }
49
+ if (typeof value === "string") {
50
+ return applyPatterns(value, patterns, replacement);
51
+ }
52
+ if (Array.isArray(value)) {
53
+ return value.map((item) => redactValue(item, keys, patterns, replacement));
54
+ }
55
+ if (value instanceof Error) {
56
+ return {
57
+ name: value.name,
58
+ message: value.message,
59
+ stack: value.stack
60
+ };
61
+ }
62
+ if (value !== null && typeof value === "object") {
63
+ const result = {};
64
+ for (const [k, v] of Object.entries(value)) {
65
+ result[k] = redactValue(v, keys, patterns, replacement, k);
66
+ }
67
+ return result;
68
+ }
69
+ return value;
70
+ }
71
+ function processMetadata(metadata, redactionConfig) {
72
+ if (!metadata)
73
+ return;
74
+ const processed = {};
75
+ for (const [key, value] of Object.entries(metadata)) {
76
+ if (value instanceof Error) {
77
+ processed[key] = {
78
+ name: value.name,
79
+ message: value.message,
80
+ stack: value.stack
81
+ };
82
+ } else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
83
+ processed[key] = processNestedForErrors(value);
84
+ } else {
85
+ processed[key] = value;
86
+ }
87
+ }
88
+ if (redactionConfig?.enabled === false) {
89
+ return processed;
90
+ }
91
+ const hasGlobalRules = (globalRedactionConfig.patterns?.length ?? 0) > 0 || (globalRedactionConfig.keys?.length ?? 0) > 0;
92
+ if (redactionConfig || hasGlobalRules) {
93
+ const allPatterns = [
94
+ ...DEFAULT_PATTERNS,
95
+ ...redactionConfig?.patterns ?? [],
96
+ ...globalRedactionConfig.patterns ?? []
97
+ ];
98
+ const allKeys = [
99
+ ...redactionConfig?.keys ?? [],
100
+ ...globalRedactionConfig.keys ?? []
101
+ ];
102
+ const replacement = redactionConfig?.replacement ?? "[REDACTED]";
103
+ return redactValue(processed, allKeys, allPatterns, replacement);
104
+ }
105
+ return processed;
106
+ }
107
+ function processNestedForErrors(obj) {
108
+ if (obj instanceof Error) {
109
+ return {
110
+ name: obj.name,
111
+ message: obj.message,
112
+ stack: obj.stack
113
+ };
114
+ }
115
+ if (Array.isArray(obj)) {
116
+ return obj.map((item) => {
117
+ if (item !== null && typeof item === "object") {
118
+ return processNestedForErrors(item);
119
+ }
120
+ return item;
121
+ });
122
+ }
123
+ const result = {};
124
+ for (const [key, value] of Object.entries(obj)) {
125
+ if (value instanceof Error) {
126
+ result[key] = {
127
+ name: value.name,
128
+ message: value.message,
129
+ stack: value.stack
130
+ };
131
+ } else if (value !== null && typeof value === "object") {
132
+ result[key] = processNestedForErrors(value);
133
+ } else {
134
+ result[key] = value;
135
+ }
136
+ }
137
+ return result;
138
+ }
139
+ function createLoggerFromState(state) {
140
+ const log = (level, message, metadata) => {
141
+ if (!shouldLog(level, state.level))
142
+ return;
143
+ const processedMetadata = processMetadata({ ...state.context, ...metadata }, state.redaction);
144
+ let processedMessage = message;
145
+ if (state.redaction?.enabled !== false) {
146
+ const hasGlobalRules = (globalRedactionConfig.patterns?.length ?? 0) > 0 || (globalRedactionConfig.keys?.length ?? 0) > 0;
147
+ if (state.redaction || hasGlobalRules) {
148
+ const allPatterns = [
149
+ ...DEFAULT_PATTERNS,
150
+ ...state.redaction?.patterns ?? [],
151
+ ...globalRedactionConfig.patterns ?? []
152
+ ];
153
+ const replacement = state.redaction?.replacement ?? "[REDACTED]";
154
+ processedMessage = applyPatterns(message, allPatterns, replacement);
155
+ }
156
+ }
157
+ const record = {
158
+ timestamp: Date.now(),
159
+ level,
160
+ category: state.name,
161
+ message: processedMessage,
162
+ ...processedMetadata !== undefined ? { metadata: processedMetadata } : {}
163
+ };
164
+ for (const sink of state.sinks) {
165
+ try {
166
+ let formatted;
167
+ if (sink.formatter) {
168
+ formatted = sink.formatter.format(record);
169
+ }
170
+ sink.write(record, formatted);
171
+ } catch {}
172
+ }
173
+ };
174
+ return {
175
+ trace: (message, metadata) => log("trace", message, metadata),
176
+ debug: (message, metadata) => log("debug", message, metadata),
177
+ info: (message, metadata) => log("info", message, metadata),
178
+ warn: (message, metadata) => log("warn", message, metadata),
179
+ error: (message, metadata) => log("error", message, metadata),
180
+ fatal: (message, metadata) => log("fatal", message, metadata),
181
+ getContext: () => ({ ...state.context }),
182
+ setLevel: (level) => {
183
+ state.level = level;
184
+ },
185
+ addSink: (sink) => {
186
+ state.sinks.push(sink);
187
+ registeredSinks.add(sink);
188
+ },
189
+ child: (context) => {
190
+ const childState = {
191
+ name: state.name,
192
+ level: state.level,
193
+ context: { ...state.context, ...context },
194
+ sinks: state.sinks,
195
+ redaction: state.redaction
196
+ };
197
+ return createLoggerFromState(childState);
198
+ }
199
+ };
200
+ }
201
+ function createLogger(config) {
202
+ const state = {
203
+ name: config.name,
204
+ level: config.level ?? "info",
205
+ context: config.context ?? {},
206
+ sinks: [...config.sinks ?? []],
207
+ redaction: config.redaction
208
+ };
209
+ for (const sink of state.sinks) {
210
+ registeredSinks.add(sink);
211
+ }
212
+ return createLoggerFromState(state);
213
+ }
214
+ function createChildLogger(parent, context) {
215
+ const parentContext = parent.getContext();
216
+ const mergedContext = { ...parentContext, ...context };
217
+ const childLogger = {
218
+ trace: (message, metadata) => parent.trace(message, { ...context, ...metadata }),
219
+ debug: (message, metadata) => parent.debug(message, { ...context, ...metadata }),
220
+ info: (message, metadata) => parent.info(message, { ...context, ...metadata }),
221
+ warn: (message, metadata) => parent.warn(message, { ...context, ...metadata }),
222
+ error: (message, metadata) => parent.error(message, { ...context, ...metadata }),
223
+ fatal: (message, metadata) => parent.fatal(message, { ...context, ...metadata }),
224
+ getContext: () => mergedContext,
225
+ setLevel: (level) => parent.setLevel(level),
226
+ addSink: (sink) => parent.addSink(sink),
227
+ child: (newContext) => createChildLogger(childLogger, newContext)
228
+ };
229
+ return childLogger;
230
+ }
231
+ function createJsonFormatter() {
232
+ return {
233
+ format(record) {
234
+ const { timestamp, level, category, message, metadata } = record;
235
+ const output = {
236
+ timestamp,
237
+ level,
238
+ category,
239
+ message,
240
+ ...metadata
241
+ };
242
+ return JSON.stringify(output);
243
+ }
244
+ };
245
+ }
246
+ function createPrettyFormatter(options) {
247
+ const useColors = options?.colors ?? false;
248
+ const showTimestamp = options?.timestamp ?? true;
249
+ const ANSI = {
250
+ reset: "\x1B[0m",
251
+ dim: "\x1B[2m",
252
+ yellow: "\x1B[33m",
253
+ red: "\x1B[31m",
254
+ cyan: "\x1B[36m",
255
+ green: "\x1B[32m",
256
+ magenta: "\x1B[35m"
257
+ };
258
+ const levelColors = {
259
+ trace: ANSI.dim,
260
+ debug: ANSI.cyan,
261
+ info: ANSI.green,
262
+ warn: ANSI.yellow,
263
+ error: ANSI.red,
264
+ fatal: ANSI.magenta
265
+ };
266
+ return {
267
+ format(record) {
268
+ const { timestamp, level, category, message, metadata } = record;
269
+ const isoTime = new Date(timestamp).toISOString();
270
+ const levelUpper = level.toUpperCase();
271
+ let output = "";
272
+ if (showTimestamp) {
273
+ output += `${isoTime} `;
274
+ }
275
+ if (useColors) {
276
+ const color = levelColors[level];
277
+ output += `${color}[${levelUpper}]${ANSI.reset} `;
278
+ } else {
279
+ output += `[${levelUpper}] `;
280
+ }
281
+ output += `${category}: ${message}`;
282
+ if (metadata && Object.keys(metadata).length > 0) {
283
+ output += ` ${JSON.stringify(metadata)}`;
284
+ }
285
+ return output;
286
+ }
287
+ };
288
+ }
289
+ function createConsoleSink() {
290
+ const formatter = createPrettyFormatter({ colors: true });
291
+ const sink = {
292
+ formatter,
293
+ write(record, formatted) {
294
+ const output = formatted ?? formatter.format(record);
295
+ const outputWithNewline = output.endsWith(`
296
+ `) ? output : `${output}
297
+ `;
298
+ if (LEVEL_PRIORITY[record.level] >= LEVEL_PRIORITY.warn) {
299
+ process.stderr.write(outputWithNewline);
300
+ } else {
301
+ process.stdout.write(outputWithNewline);
302
+ }
303
+ }
304
+ };
305
+ registeredSinks.add(sink);
306
+ return sink;
307
+ }
308
+ function createFileSink(options) {
309
+ const formatter = createJsonFormatter();
310
+ const buffer = [];
311
+ const { path } = options;
312
+ const append = options.append ?? true;
313
+ if (!append) {
314
+ writeFileSync(path, "");
315
+ }
316
+ const sink = {
317
+ formatter,
318
+ write(record, formatted) {
319
+ const output = formatted ?? formatter.format(record);
320
+ const outputWithNewline = output.endsWith(`
321
+ `) ? output : `${output}
322
+ `;
323
+ buffer.push(outputWithNewline);
324
+ },
325
+ async flush() {
326
+ if (buffer.length > 0) {
327
+ const content = buffer.join("");
328
+ buffer.length = 0;
329
+ const file = Bun.file(path);
330
+ const existing = await file.exists() ? await file.text() : "";
331
+ await Bun.write(path, existing + content);
332
+ }
333
+ }
334
+ };
335
+ registeredSinks.add(sink);
336
+ return sink;
337
+ }
338
+ function configureRedaction(config) {
339
+ if (config.patterns) {
340
+ globalRedactionConfig.patterns = [
341
+ ...globalRedactionConfig.patterns ?? [],
342
+ ...config.patterns
343
+ ];
344
+ }
345
+ if (config.keys) {
346
+ globalRedactionConfig.keys = [
347
+ ...globalRedactionConfig.keys ?? [],
348
+ ...config.keys
349
+ ];
350
+ }
351
+ }
352
+ async function flush() {
353
+ const flushPromises = [];
354
+ for (const sink of registeredSinks) {
355
+ if (sink.flush) {
356
+ flushPromises.push(sink.flush());
357
+ }
358
+ }
359
+ await Promise.all(flushPromises);
360
+ }
361
+ export {
362
+ flush,
363
+ createPrettyFormatter,
364
+ createLogger,
365
+ createJsonFormatter,
366
+ createFileSink,
367
+ createConsoleSink,
368
+ createChildLogger,
369
+ configureRedaction,
370
+ DEFAULT_PATTERNS
371
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@outfitter/logging",
3
+ "description": "Structured logging via logtape with redaction support for Outfitter",
4
+ "version": "0.1.0-rc.1",
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "build": "bunup --filter @outfitter/logging",
23
+ "lint": "biome lint ./src",
24
+ "lint:fix": "biome lint --write ./src",
25
+ "test": "bun test",
26
+ "typecheck": "tsc --noEmit",
27
+ "clean": "rm -rf dist"
28
+ },
29
+ "dependencies": {
30
+ "@outfitter/contracts": "workspace:*",
31
+ "@logtape/logtape": "^2.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "latest",
35
+ "typescript": "^5.8.0"
36
+ },
37
+ "keywords": [
38
+ "outfitter",
39
+ "logging",
40
+ "logtape",
41
+ "structured",
42
+ "typescript"
43
+ ],
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/outfitter-dev/outfitter.git",
48
+ "directory": "packages/logging"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }