@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 +338 -0
- package/dist/index.d.ts +504 -0
- package/dist/index.js +371 -0
- package/package.json +53 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|