@stonyx/logs 1.0.1-alpha.20 → 1.0.1-alpha.22
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 +28 -182
- package/dist/index.d.ts +0 -112
- package/dist/index.js +47 -135
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,11 +57,7 @@ const log = new Log();
|
|
|
57
57
|
|
|
58
58
|
log.info('Info: sample application has started');
|
|
59
59
|
log.warn('Warning: this is just a sample');
|
|
60
|
-
|
|
61
|
-
// logs to logs/error.log; a failed write rejects, and handling that rejection is required
|
|
62
|
-
// see "Handling write failures" below
|
|
63
|
-
log.error('Error: no application logic detected', true)
|
|
64
|
-
.catch(err => { /* err.code is the underlying fs error code, e.g. 'EACCES' */ });
|
|
60
|
+
log.error('Error: no application logic detected', true); // logs to logs/error.log file
|
|
65
61
|
```
|
|
66
62
|
|
|
67
63
|
Easily define your own logging mechanism and color-coding preference:
|
|
@@ -95,15 +91,8 @@ const log = new Log({
|
|
|
95
91
|
suffix: '\n=============================================================== \n',
|
|
96
92
|
});
|
|
97
93
|
|
|
98
|
-
log.info('Info: sample application has started')
|
|
99
|
-
.catch(err => { /* see "Handling write failures" */ });
|
|
94
|
+
log.info('Info: sample application has started');
|
|
100
95
|
```
|
|
101
|
-
|
|
102
|
-
> **Caveat:** with `logToFileByDefault: true`, every log call *except* [`debug()`](#the-debug-method)
|
|
103
|
-
> becomes a file write, and therefore returns a promise that can reject. A failing log directory turns
|
|
104
|
-
> an unhandled `log.info()` into a process-terminating rejection. See
|
|
105
|
-
> [handling write failures](#handling-write-failures).
|
|
106
|
-
|
|
107
96
|

|
|
108
97
|
|
|
109
98
|
|
|
@@ -118,7 +107,6 @@ const log = new Log({ additionalLogs: { question: 'green' } });
|
|
|
118
107
|
log.defineType('query', log.chalk().black.bgGreen);
|
|
119
108
|
|
|
120
109
|
log.question('What will a fully custom chalk color function look like?');
|
|
121
|
-
// second argument writes to file, so this can reject -- see "Handling write failures"
|
|
122
110
|
await log.query('This is what a custom chalk color setting looks like', true);
|
|
123
111
|
```
|
|
124
112
|

|
|
@@ -165,23 +153,37 @@ These methods can then be called in your application with [logging parameters](#
|
|
|
165
153
|
|
|
166
154
|
Color settings are handled by determining whether your input is a color name or a hex value (prefixed with **#**). For example, passing `red` as a color setting will utilize `chalk.red`, while passing `#ff0000` would use `chalk.hex('#ff0000')` instead. A [list of available colors](https://github.com/chalk/chalk#colors) can be found in chalks' documentation.
|
|
167
155
|
|
|
168
|
-
Additionally, these methods return a promise when `logToFile` is true, allowing you
|
|
169
|
-
|
|
170
|
-
When `logToFile` is true that promise **can reject**, and handling the rejection is **required** --
|
|
171
|
-
see [handling write failures](#handling-write-failures) for the full contract.
|
|
156
|
+
Additionally, these methods return a promise when `logToFile` is true, allowing you use them with `await` in an async method, or append `then(), catch(), or finally()` for more advanced callback usage.
|
|
172
157
|
|
|
173
158
|
```js
|
|
174
159
|
async method() {
|
|
175
|
-
|
|
176
|
-
await log.error('error message', true);
|
|
160
|
+
await log.error('error message', true);
|
|
177
161
|
|
|
178
|
-
|
|
179
|
-
} catch (err) {
|
|
180
|
-
// err.code is the underlying fs error code, e.g. 'EACCES'
|
|
181
|
-
}
|
|
162
|
+
// do something after logs/error.log (default) is created
|
|
182
163
|
}
|
|
183
164
|
```
|
|
184
165
|
|
|
166
|
+
#### File Write Failures
|
|
167
|
+
|
|
168
|
+
When `logToFile` is true, **the returned promise rejecting is the only failure signal.** Nothing is
|
|
169
|
+
printed and no fallback log is written when the log directory or file cannot be written: the
|
|
170
|
+
underlying `fs` error is propagated to the caller with its `code` intact (`EACCES`, `EPERM`,
|
|
171
|
+
`EROFS`, ...).
|
|
172
|
+
|
|
173
|
+
A fire-and-forget call therefore produces an **unhandled promise rejection** on a failed write.
|
|
174
|
+
Always `await` the call (or attach a `.catch()`) anywhere file logging is enabled:
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
// unhandled rejection if the log directory is not writable
|
|
178
|
+
log.error('error message', true);
|
|
179
|
+
|
|
180
|
+
// handled
|
|
181
|
+
log.error('error message', true).catch(err => process.stderr.write(`log write failed: ${err.code}\n`));
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A write that fails because the log directory was removed at runtime is retried once against a
|
|
185
|
+
freshly created directory before the rejection surfaces.
|
|
186
|
+
|
|
185
187
|
### The Debug Method
|
|
186
188
|
|
|
187
189
|
**Log** allows for the `log.debug()` method to be overridden by a color setting. However, by default we do not define a color for debug and debug is handled differently. For console logging, all **debug** does is output the following:
|
|
@@ -196,11 +198,6 @@ JSON.stringify(content, null, 2);
|
|
|
196
198
|
|
|
197
199
|
We believe that when wanting to output complicated objects or debug **typescript** applications, there are better methods than utilizing this **Log** package. But for anyone who's fully incorporated **Log** into their project, this function offers some convenience.
|
|
198
200
|
|
|
199
|
-
`debug(content, logToFile)` writes to file on the same contract as every other log type -- a failed
|
|
200
|
-
write rejects with the underlying `fs` error and must be handled -- with one difference: `debug()`
|
|
201
|
-
ignores [`logToFileByDefault`](#configuration), so it writes to file only when you pass `true`.
|
|
202
|
-
See [handling write failures](#handling-write-failures).
|
|
203
|
-
|
|
204
201
|
### Logging Parameters
|
|
205
202
|
|
|
206
203
|
```js
|
|
@@ -210,161 +207,10 @@ log.error('error message', true, false); // content, logToFile, overwrite
|
|
|
210
207
|
| Parameter | Type | Default | Description |
|
|
211
208
|
| :---: | :---: | :---: | :--- |
|
|
212
209
|
| `content` | **String** | | Content of log that will output on your console. |
|
|
213
|
-
| `logToFile` | **Boolean** | *false* | Option to log content to file.
|
|
210
|
+
| `logToFile` | **Boolean** | *false* | Option to log content to file. |
|
|
214
211
|
| `overwrite` | **Boolean** | *false <br> (true on debug())* | Option to overwrite log file, rather than append to it. This option is redundant if logToFile is false. |
|
|
215
212
|
|
|
216
213
|
**logToFile** will log to *<project-root>/logs* unless [configured](#configuration) differently during instantiation. <br>
|
|
217
|
-
|
|
218
|
-
### Handling Write Failures
|
|
219
|
-
|
|
220
|
-
Any call that writes to a file returns a promise that **rejects when the write fails**. That covers
|
|
221
|
-
`log.error(content, true)`, `log.debug(content, true)`, and every log call except `debug()` when
|
|
222
|
-
[`logToFileByDefault`](#configuration) is `true` -- `debug()` is a direct method that never consults
|
|
223
|
-
`logToFileByDefault`, so it only writes to file when you pass `true` explicitly.
|
|
224
|
-
|
|
225
|
-
That rejection is the only actionable failure signal, so every file-writing call must be awaited
|
|
226
|
-
inside `try`/`catch`, or have `.catch()` attached. The console line is written *before* the file
|
|
227
|
-
write is attempted, so a rejection means only the file copy was lost -- do not re-log the message in
|
|
228
|
-
your handler, or it prints twice.
|
|
229
|
-
|
|
230
|
-
> **A rejection is terminal. Do not retry it.**
|
|
231
|
-
>
|
|
232
|
-
> For the recoverable directory-level codes -- `ENOENT`, `EACCES`, `EPERM`, `EROFS` -- **Log** has
|
|
233
|
-
> already retried internally by the time the rejection reaches you (see
|
|
234
|
-
> [self-healing retry](#self-healing-retry) below), so that retry ran and failed too. Every other
|
|
235
|
-
> code -- `EISDIR`, `ENOTDIR` and `ENOSPC` among them -- is not itself retried, because a retry
|
|
236
|
-
> cannot help it. Either way a consumer-side retry layer races the internal self-heal or repeats a call that
|
|
237
|
-
> already cannot succeed, and only delays the disable or back-off that your error path exists to
|
|
238
|
-
> trigger.
|
|
239
|
-
>
|
|
240
|
-
> Treat the **first** rejection as the signal to disable file logging or back off, and do not attempt
|
|
241
|
-
> the write again straight away. If you do want file logging to come back without a restart, re-arm
|
|
242
|
-
> on a timer -- see [recovering after a latch](#recovering-after-a-latch).
|
|
243
|
-
|
|
244
|
-
#### What the Promise Rejects With
|
|
245
|
-
|
|
246
|
-
The rejection value is the underlying Node `fs` error - a `NodeJS.ErrnoException` passed through
|
|
247
|
-
unmodified, with `err.code`, `err.syscall`, `err.errno` and `err.path` all preserved.
|
|
248
|
-
|
|
249
|
-
```js
|
|
250
|
-
try {
|
|
251
|
-
await log.error('error message', true);
|
|
252
|
-
} catch (err) {
|
|
253
|
-
// err.code is the underlying fs error code, e.g. 'EACCES'
|
|
254
|
-
// err.syscall is the failing call, e.g. 'mkdir' or 'open'
|
|
255
|
-
}
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
Codes you are most likely to see are `EACCES` and `EPERM` (no permission to create the log directory
|
|
259
|
-
or write the log file), `ENOENT` (the directory disappeared and could not be recreated) and `EROFS`
|
|
260
|
-
(read-only filesystem) - but any error the filesystem raises reaches the caller as-is.
|
|
261
|
-
|
|
262
|
-
**An unhandled rejection terminates your process** under Node's default `--unhandled-rejections=throw`.
|
|
263
|
-
A fire-and-forget `log.error('...', true)` pointed at an unwritable log directory exits the process
|
|
264
|
-
with `Error: EACCES: permission denied, mkdir '...'`.
|
|
265
|
-
|
|
266
|
-
The recommended consumer shape is a latch that trips on the first rejection:
|
|
267
|
-
|
|
268
|
-
```js
|
|
269
|
-
let fileLoggingDisabled = false;
|
|
270
|
-
|
|
271
|
-
async function logError(message) {
|
|
272
|
-
// once file logging has failed, stay on the console
|
|
273
|
-
if (fileLoggingDisabled) return log.error(message);
|
|
274
|
-
|
|
275
|
-
try {
|
|
276
|
-
await log.error(message, true);
|
|
277
|
-
} catch (err) {
|
|
278
|
-
// the first rejection is terminal - disable, do not retry
|
|
279
|
-
fileLoggingDisabled = true;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
That latch is deliberately terminal for the lifetime of the process: nothing in it ever re-enables
|
|
285
|
-
file logging. Records are degraded rather than lost, because the console line is still written on
|
|
286
|
-
every call -- but a consumer using the file sink for audit or compliance should treat the first
|
|
287
|
-
rejection as an **alertable** event, or fail closed, rather than degrade silently.
|
|
288
|
-
|
|
289
|
-
#### Recovering After a Latch
|
|
290
|
-
|
|
291
|
-
If file logging must come back without a restart, re-arm the latch on a **timer**, never on the next
|
|
292
|
-
log call. Hold it closed for a fixed back-off window -- a minute or more, long enough that a
|
|
293
|
-
permissions fix or a freed disk has a chance to land -- then let exactly one write through. If that
|
|
294
|
-
write rejects, latch again and lengthen the window; if it succeeds, clear the latch. Anything faster
|
|
295
|
-
is a consumer-side retry under another name.
|
|
296
|
-
|
|
297
|
-
This also decides what your operators can see. `log-write-recovered` (see
|
|
298
|
-
[structured stderr notices](#structured-stderr-notices)) is emitted **only** on a subsequent
|
|
299
|
-
successful write, and a consumer that latches off permanently never issues one. Its entire stderr
|
|
300
|
-
output for the episode is a single `log-write-failed` followed by silence, which is indistinguishable
|
|
301
|
-
from a failure that self-healed -- so an alert-clear rule keyed on `log-write-recovered` will never
|
|
302
|
-
fire for it. Either treat `log-write-failed` as latching on the operator side too, or use the timed
|
|
303
|
-
re-arm above, which is what makes a recovery notice reachable at all.
|
|
304
|
-
|
|
305
|
-
#### Self-Healing Retry
|
|
306
|
-
|
|
307
|
-
A cached log directory can outlive the directory it describes - for example, something deletes
|
|
308
|
-
`logs/` while your process is running. To cover that, a failure whose `err.code` is `ENOENT`,
|
|
309
|
-
`EACCES`, `EPERM` or `EROFS` drops the cached directory entry and retries the write **exactly once**.
|
|
310
|
-
Any other code -- `EISDIR`, `ENOTDIR`, `ENOSPC`, `EMFILE` and the rest -- does not itself trigger
|
|
311
|
-
a retry. The decision is keyed on the *first* attempt's code, so a rejection carrying one of these
|
|
312
|
-
can still arrive after a retry if conditions changed mid-call.
|
|
313
|
-
|
|
314
|
-
A retry that succeeds emits no *failure* notice: the promise resolves normally and the write counts
|
|
315
|
-
as a success, closing any failure episode already open for that directory with the usual
|
|
316
|
-
`log-write-recovered`. A rejection carrying one of those four codes has therefore already spent its
|
|
317
|
-
single retry; every other code was never retryable in the first place. The retry is per call and per
|
|
318
|
-
`Log` instance -- independent instances retry independently.
|
|
319
|
-
|
|
320
|
-
#### Structured stderr Notices
|
|
321
|
-
|
|
322
|
-
Alongside the rejection, **Log** emits a machine-parseable notice to `stderr` so that operators have
|
|
323
|
-
something to key on. The write-failure path must never re-enter the logger itself, so these notices
|
|
324
|
-
bypass all formatting and configuration: they are emitted via `console.error` as one JSON object per
|
|
325
|
-
line (JSONL), regardless of your color, prefix, suffix or path settings. Anything that wraps or
|
|
326
|
-
patches `console.error` sees them too. Notices are informational; the promise rejection is the
|
|
327
|
-
failure signal.
|
|
328
|
-
|
|
329
|
-
| Field | Value |
|
|
330
|
-
| :---: | :--- |
|
|
331
|
-
| `ts` | ISO-8601 timestamp of the notice |
|
|
332
|
-
| `surface` | Always `"stonyx-logs"` |
|
|
333
|
-
| `sessionKey` | Always `null` |
|
|
334
|
-
| `project` | Always `null` |
|
|
335
|
-
| `severity` | `"error"` for a failure, `"warn"` for a recovery |
|
|
336
|
-
| `event` | `"log-write-failed"` or `"log-write-recovered"` |
|
|
337
|
-
| `payload` | Event-specific, see below |
|
|
338
|
-
| `schemaVersion` | Always `1` |
|
|
339
|
-
|
|
340
|
-
`log-write-failed` payload:
|
|
341
|
-
|
|
342
|
-
| Field | Description |
|
|
343
|
-
| :---: | :--- |
|
|
344
|
-
| `targetLog` | Resolved path of the log file that could not be written |
|
|
345
|
-
| `path` | Resolved log *directory*, with trailing separator - this is the dedupe key |
|
|
346
|
-
| `syscall` | The failing syscall, e.g. `"mkdir"` or `"open"` |
|
|
347
|
-
| `code` | The same `err.code` carried by the rejection |
|
|
348
|
-
| `cached` | Whether the directory was already cached when the write started |
|
|
349
|
-
| `suppressedCount` | Always `0` on a failure notice |
|
|
350
|
-
|
|
351
|
-
`log-write-recovered` carries `targetLog`, `path` and `suppressedCount` only.
|
|
352
|
-
|
|
353
|
-
**Dedupe:** notices are one per failure episode, keyed on the resolved directory and scoped to the
|
|
354
|
-
`Log` instance -- the failure counters are instance state, so two instances writing to the same
|
|
355
|
-
directory emit one notice each. The first failure for a directory emits `log-write-failed`; every
|
|
356
|
-
subsequent failure for that same directory is silent. The next successful write to it emits a single
|
|
357
|
-
`log-write-recovered` carrying `suppressedCount` - the number of notices that were suppressed, i.e.
|
|
358
|
-
`N - 1` for an episode of `N` consecutive failures.
|
|
359
|
-
Every failure still rejects its own promise; only the notices are deduped.
|
|
360
|
-
|
|
361
|
-
Five consecutive failures followed by one success emit exactly these two records:
|
|
362
|
-
|
|
363
|
-
```jsonl
|
|
364
|
-
{"ts":"2026-08-29T23:08:50.343Z","surface":"stonyx-logs","sessionKey":null,"project":null,"severity":"error","event":"log-write-failed","payload":{"targetLog":"/private/tmp/my-app/logs/error.log","path":"/private/tmp/my-app/logs/","syscall":"mkdir","code":"EACCES","cached":false,"suppressedCount":0},"schemaVersion":1}
|
|
365
|
-
{"ts":"2026-08-29T23:08:50.344Z","surface":"stonyx-logs","sessionKey":null,"project":null,"severity":"warn","event":"log-write-recovered","payload":{"targetLog":"/private/tmp/my-app/logs/error.log","path":"/private/tmp/my-app/logs/","suppressedCount":4},"schemaVersion":1}
|
|
366
|
-
```
|
|
367
|
-
|
|
368
214
|
### Configuration
|
|
369
215
|
|
|
370
216
|
When instantiating **Log**, you can pass an object to customize your settings. Below is the default configuration:
|
|
@@ -388,7 +234,7 @@ const log = new Log({
|
|
|
388
234
|
|
|
389
235
|
| Option | Type | Default | Description |
|
|
390
236
|
| :---: | :---: | :---: | :--- |
|
|
391
|
-
| `logToFileByDefault` | **Boolean** | *false* | Option to change default setting for `logToFile` parameter of logging functions.
|
|
237
|
+
| `logToFileByDefault` | **Boolean** | *false* | Option to change default setting for `logToFile` parameter of logging functions. |
|
|
392
238
|
| `logTimestamp` | **Boolean** | *false* | Option to include timestamp in console logging. Timestamps are automatically included in file logs. |
|
|
393
239
|
| `path` | **String** | *'logs/'* | Path in which to store log files. This setting is relative to your project's root directory. |
|
|
394
240
|
| `prefix` | **String** | *''* | Prefix string to prepend all log messages for all log types with the exception of *debug*. |
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import Color, { type ColorSetting } from './color.js';
|
|
2
|
-
export type Severity = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
|
3
2
|
export interface LogOptions {
|
|
4
3
|
logToFileByDefault: boolean;
|
|
5
4
|
logTimestamp: boolean;
|
|
@@ -15,131 +14,20 @@ export default class Log {
|
|
|
15
14
|
color: Color;
|
|
16
15
|
typeOptions: Record<string, Partial<LogOptions>>;
|
|
17
16
|
directoryCache: Map<string, Promise<void>>;
|
|
18
|
-
writeFailures: Map<string, number>;
|
|
19
17
|
[key: string]: unknown;
|
|
20
|
-
/**
|
|
21
|
-
* Logs `content` to the console in the `info` color, and to file when `logToFile` is true.
|
|
22
|
-
*
|
|
23
|
-
* **A failed file write rejects the returned promise, and that rejection is terminal - do not retry
|
|
24
|
-
* it.** `ENOENT`, `EACCES`, `EPERM` and `EROFS` have already been retried once internally by the
|
|
25
|
-
* time one reaches you; every other code (`EISDIR`, `ENOTDIR`, `ENOSPC`, `EMFILE`, ...) is never
|
|
26
|
-
* retried, because a retry cannot help it. Handle the first rejection by disabling file logging or
|
|
27
|
-
* backing off - never by writing again. The rejection value is the underlying
|
|
28
|
-
* `NodeJS.ErrnoException` with `err.code` preserved, and leaving it unhandled terminates the
|
|
29
|
-
* process.
|
|
30
|
-
*
|
|
31
|
-
* @see https://github.com/abofs/stonyx-logs#handling-write-failures
|
|
32
|
-
*/
|
|
33
18
|
info: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
|
|
34
|
-
/**
|
|
35
|
-
* Logs `content` to the console in the `warn` color, and to file when `logToFile` is true.
|
|
36
|
-
*
|
|
37
|
-
* **A failed file write rejects the returned promise, and that rejection is terminal - do not retry
|
|
38
|
-
* it.** `ENOENT`, `EACCES`, `EPERM` and `EROFS` have already been retried once internally by the
|
|
39
|
-
* time one reaches you; every other code (`EISDIR`, `ENOTDIR`, `ENOSPC`, `EMFILE`, ...) is never
|
|
40
|
-
* retried, because a retry cannot help it. Handle the first rejection by disabling file logging or
|
|
41
|
-
* backing off - never by writing again. The rejection value is the underlying
|
|
42
|
-
* `NodeJS.ErrnoException` with `err.code` preserved, and leaving it unhandled terminates the
|
|
43
|
-
* process.
|
|
44
|
-
*
|
|
45
|
-
* @see https://github.com/abofs/stonyx-logs#handling-write-failures
|
|
46
|
-
*/
|
|
47
19
|
warn: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
|
|
48
|
-
/**
|
|
49
|
-
* Logs `content` to the console in the `error` color, and to file when `logToFile` is true.
|
|
50
|
-
*
|
|
51
|
-
* **A failed file write rejects the returned promise, and that rejection is terminal - do not retry
|
|
52
|
-
* it.** `ENOENT`, `EACCES`, `EPERM` and `EROFS` have already been retried once internally by the
|
|
53
|
-
* time one reaches you; every other code (`EISDIR`, `ENOTDIR`, `ENOSPC`, `EMFILE`, ...) is never
|
|
54
|
-
* retried, because a retry cannot help it. Handle the first rejection by disabling file logging or
|
|
55
|
-
* backing off - never by writing again. The rejection value is the underlying
|
|
56
|
-
* `NodeJS.ErrnoException` with `err.code` preserved, and leaving it unhandled terminates the
|
|
57
|
-
* process.
|
|
58
|
-
*
|
|
59
|
-
* @see https://github.com/abofs/stonyx-logs#handling-write-failures
|
|
60
|
-
*/
|
|
61
20
|
error: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
|
|
62
21
|
constructor(options?: Partial<LogOptions>);
|
|
63
22
|
defineType(type: string, setting: ColorSetting, options?: Partial<LogOptions> | null): void;
|
|
64
23
|
createConvenienceMethod(type: string): void;
|
|
65
|
-
/**
|
|
66
|
-
* Validates params and applies configuration-based defaults for logging. Every convenience method
|
|
67
|
-
* created by `defineType` - including `info`, `warn` and `error` - routes through here. Note that
|
|
68
|
-
* `debug` does not: it is a direct method that never consults `logToFileByDefault`.
|
|
69
|
-
*
|
|
70
|
-
* **A failed file write rejects the returned promise, and that rejection is terminal - do not retry
|
|
71
|
-
* it.** `ENOENT`, `EACCES`, `EPERM` and `EROFS` have already been retried once internally by the
|
|
72
|
-
* time one reaches you; every other code (`EISDIR`, `ENOTDIR`, `ENOSPC`, `EMFILE`, ...) is never
|
|
73
|
-
* retried, because a retry cannot help it. Handle the first rejection by disabling file logging or
|
|
74
|
-
* backing off - never by writing again. The rejection value is the underlying
|
|
75
|
-
* `NodeJS.ErrnoException` with `err.code` preserved, and leaving it unhandled terminates the
|
|
76
|
-
* process.
|
|
77
|
-
*
|
|
78
|
-
* @see https://github.com/abofs/stonyx-logs#handling-write-failures
|
|
79
|
-
*/
|
|
80
24
|
logAction(type: string, content: string, logToFile?: boolean, overwrite?: boolean): Promise<void>;
|
|
81
25
|
getOptionForType(type: string, option: keyof LogOptions): LogOptions[keyof LogOptions];
|
|
82
26
|
chalk(): ReturnType<Color['getChalkInstance']>;
|
|
83
|
-
/**
|
|
84
|
-
* Logs to console, and conditionally to file.
|
|
85
|
-
*
|
|
86
|
-
* When `logToFile` is true this awaits `writeToFile` and propagates its rejection unchanged, so
|
|
87
|
-
* the caller receives the underlying `NodeJS.ErrnoException`. Callers must handle it: see the
|
|
88
|
-
* `writeToFile` contract below.
|
|
89
|
-
*/
|
|
90
27
|
log(content: string, type: string, logToFile: boolean, overwrite: boolean): Promise<void>;
|
|
91
|
-
/**
|
|
92
|
-
* Direct hardcoded debug method (limited file logging). Unlike the `defineType` convenience
|
|
93
|
-
* methods this does not route through `logAction`, so it never consults `logToFileByDefault` and
|
|
94
|
-
* only writes to file when `logToFile` is passed explicitly.
|
|
95
|
-
*
|
|
96
|
-
* When `logToFile` is true this awaits `writeToFile` and propagates its rejection unchanged, on
|
|
97
|
-
* exactly the same contract as `log` - see `writeToFile` below.
|
|
98
|
-
*/
|
|
99
28
|
debug(content: unknown, logToFile?: boolean, overwrite?: boolean): Promise<void>;
|
|
100
|
-
/**
|
|
101
|
-
* Writes `content` to the resolved target for `type`, creating the target's directory on first
|
|
102
|
-
* use for that directory.
|
|
103
|
-
*
|
|
104
|
-
* The returned promise rejecting with the underlying `NodeJS.ErrnoException` (with `err.code`
|
|
105
|
-
* preserved) is the sole failure signal - callers that care must handle it. The structured
|
|
106
|
-
* stderr notice emitted alongside a failure is informational only and is deduped per episode.
|
|
107
|
-
*
|
|
108
|
-
* On `ENOENT`, `EACCES`, `EPERM` or `EROFS` the cached directory entry is dropped and the write
|
|
109
|
-
* is retried exactly once. Other codes do not themselves trigger a retry; the decision is keyed
|
|
110
|
-
* on the first attempt's code.
|
|
111
|
-
*
|
|
112
|
-
* A rejection is terminal either way. For those four codes the self-heal has already run and
|
|
113
|
-
* failed by the time one escapes; the rest were never retryable, so repeating the call cannot
|
|
114
|
-
* change the outcome. Consumers must not layer their own retry on top - doing so either races the
|
|
115
|
-
* internal one or repeats a call that already cannot succeed, and delays the disable/back-off the
|
|
116
|
-
* rejection exists to trigger. The correct response to the first rejection is to stop writing.
|
|
117
|
-
* See README "Handling write failures".
|
|
118
|
-
*/
|
|
119
29
|
writeToFile(type: string, content: string, overwrite: boolean): Promise<void>;
|
|
120
|
-
/**
|
|
121
|
-
* One structured stderr notice per failure episode: an emit on the first failure, silence while
|
|
122
|
-
* it keeps failing, one recovery emit on the next success. A null payload records a success.
|
|
123
|
-
*
|
|
124
|
-
* Keyed on the directory rather than the resolved target, matching `directoryCache`: every code
|
|
125
|
-
* in the retry allowlist is a directory-level condition, so keying on the target would strand an
|
|
126
|
-
* un-reaped entry - and silently drop its suppressed count - whenever a `{date}` template rotates
|
|
127
|
-
* away from a still-failing filename.
|
|
128
|
-
*/
|
|
129
|
-
noticeWriteResult(path: string, targetLog: string, payload: Record<string, unknown> | null): void;
|
|
130
|
-
/**
|
|
131
|
-
* The write-failure path must never re-enter this logger, so notices go straight out through
|
|
132
|
-
* `console.error` as a single JSONL record, bypassing all of this package's formatting. Field
|
|
133
|
-
* order is the canonical one mandated by the framework logging schema, with the optional
|
|
134
|
-
* `schemaVersion` last.
|
|
135
|
-
*/
|
|
136
|
-
emitNotice(severity: Severity, event: string, payload: Record<string, unknown>): void;
|
|
137
30
|
resolveFilename(template: string, type: string): string;
|
|
138
|
-
/**
|
|
139
|
-
* Ensures the target's directory exists. Both write paths auto-create the file itself, so no
|
|
140
|
-
* bootstrap write is needed. `targetLog` is unused but retained for signature compatibility:
|
|
141
|
-
* resolveFilename strips separators, so the only cached invariant is the directory.
|
|
142
|
-
*/
|
|
143
31
|
validateFileAndDirectory(path: string, targetLog: string): Promise<void>;
|
|
144
32
|
sanitizePath(path: string): string;
|
|
145
33
|
}
|
package/dist/index.js
CHANGED
|
@@ -19,14 +19,21 @@ const defaultOptions = {
|
|
|
19
19
|
};
|
|
20
20
|
// used to sanitize defineType() options input
|
|
21
21
|
const optionKeys = Object.keys(defaultOptions);
|
|
22
|
+
/*
|
|
23
|
+
* Write failures where the cached log directory may have been removed or made
|
|
24
|
+
* unavailable at runtime. These invalidate the directory cache and are retried once.
|
|
25
|
+
*/
|
|
26
|
+
const recoverableWriteCodes = new Set(['ENOENT', 'EACCES', 'EPERM', 'EROFS']);
|
|
22
27
|
export default class Log {
|
|
23
28
|
options;
|
|
24
29
|
color;
|
|
25
30
|
typeOptions = {};
|
|
26
|
-
|
|
31
|
+
/*
|
|
32
|
+
* Instance-level cache of directory creation, keyed on the resolved directory path.
|
|
33
|
+
* resolveFilename() strips directory separators, so a filename template can never
|
|
34
|
+
* introduce a new directory and date rollover cannot invalidate an entry.
|
|
35
|
+
*/
|
|
27
36
|
directoryCache = new Map();
|
|
28
|
-
// resolved directory -> consecutive write failures, used to dedupe the operator notice
|
|
29
|
-
writeFailures = new Map();
|
|
30
37
|
constructor(options = {}) {
|
|
31
38
|
const merged = {
|
|
32
39
|
...defaultOptions,
|
|
@@ -72,21 +79,7 @@ export default class Log {
|
|
|
72
79
|
createConvenienceMethod(type) {
|
|
73
80
|
this[type] = (content, logToFile, overwrite = false) => this.logAction(type, content, logToFile, overwrite);
|
|
74
81
|
}
|
|
75
|
-
|
|
76
|
-
* Validates params and applies configuration-based defaults for logging. Every convenience method
|
|
77
|
-
* created by `defineType` - including `info`, `warn` and `error` - routes through here. Note that
|
|
78
|
-
* `debug` does not: it is a direct method that never consults `logToFileByDefault`.
|
|
79
|
-
*
|
|
80
|
-
* **A failed file write rejects the returned promise, and that rejection is terminal - do not retry
|
|
81
|
-
* it.** `ENOENT`, `EACCES`, `EPERM` and `EROFS` have already been retried once internally by the
|
|
82
|
-
* time one reaches you; every other code (`EISDIR`, `ENOTDIR`, `ENOSPC`, `EMFILE`, ...) is never
|
|
83
|
-
* retried, because a retry cannot help it. Handle the first rejection by disabling file logging or
|
|
84
|
-
* backing off - never by writing again. The rejection value is the underlying
|
|
85
|
-
* `NodeJS.ErrnoException` with `err.code` preserved, and leaving it unhandled terminates the
|
|
86
|
-
* process.
|
|
87
|
-
*
|
|
88
|
-
* @see https://github.com/abofs/stonyx-logs#handling-write-failures
|
|
89
|
-
*/
|
|
82
|
+
// validates params and sets configuration-based defaults for logging
|
|
90
83
|
logAction(type, content, logToFile, overwrite) {
|
|
91
84
|
// set logToFile default based on class options when not set
|
|
92
85
|
if (logToFile === undefined)
|
|
@@ -107,13 +100,7 @@ export default class Log {
|
|
|
107
100
|
chalk() {
|
|
108
101
|
return this.color.getChalkInstance();
|
|
109
102
|
}
|
|
110
|
-
|
|
111
|
-
* Logs to console, and conditionally to file.
|
|
112
|
-
*
|
|
113
|
-
* When `logToFile` is true this awaits `writeToFile` and propagates its rejection unchanged, so
|
|
114
|
-
* the caller receives the underlying `NodeJS.ErrnoException`. Callers must handle it: see the
|
|
115
|
-
* `writeToFile` contract below.
|
|
116
|
-
*/
|
|
103
|
+
// logs to console, and conditionally to file
|
|
117
104
|
async log(content, type, logToFile, overwrite) {
|
|
118
105
|
const logTimestamp = this.getOptionForType(type, 'logTimestamp');
|
|
119
106
|
const timestamp = `[${new Date().toLocaleString('en-US')}]`;
|
|
@@ -132,118 +119,37 @@ export default class Log {
|
|
|
132
119
|
return;
|
|
133
120
|
await this.writeToFile(type, `${timestamp} ${content}\n`, overwrite);
|
|
134
121
|
}
|
|
135
|
-
|
|
136
|
-
* Direct hardcoded debug method (limited file logging). Unlike the `defineType` convenience
|
|
137
|
-
* methods this does not route through `logAction`, so it never consults `logToFileByDefault` and
|
|
138
|
-
* only writes to file when `logToFile` is passed explicitly.
|
|
139
|
-
*
|
|
140
|
-
* When `logToFile` is true this awaits `writeToFile` and propagates its rejection unchanged, on
|
|
141
|
-
* exactly the same contract as `log` - see `writeToFile` below.
|
|
142
|
-
*/
|
|
122
|
+
// direct hardcoded debug method (log to file functionality is limited)
|
|
143
123
|
async debug(content, logToFile = false, overwrite = true) {
|
|
144
124
|
console.dir(content, { depth: 6 }); // eslint-disable-line no-console
|
|
145
125
|
if (!logToFile)
|
|
146
126
|
return;
|
|
147
127
|
await this.writeToFile('debug', JSON.stringify(content, null, 2), overwrite);
|
|
148
128
|
}
|
|
149
|
-
/**
|
|
150
|
-
* Writes `content` to the resolved target for `type`, creating the target's directory on first
|
|
151
|
-
* use for that directory.
|
|
152
|
-
*
|
|
153
|
-
* The returned promise rejecting with the underlying `NodeJS.ErrnoException` (with `err.code`
|
|
154
|
-
* preserved) is the sole failure signal - callers that care must handle it. The structured
|
|
155
|
-
* stderr notice emitted alongside a failure is informational only and is deduped per episode.
|
|
156
|
-
*
|
|
157
|
-
* On `ENOENT`, `EACCES`, `EPERM` or `EROFS` the cached directory entry is dropped and the write
|
|
158
|
-
* is retried exactly once. Other codes do not themselves trigger a retry; the decision is keyed
|
|
159
|
-
* on the first attempt's code.
|
|
160
|
-
*
|
|
161
|
-
* A rejection is terminal either way. For those four codes the self-heal has already run and
|
|
162
|
-
* failed by the time one escapes; the rest were never retryable, so repeating the call cannot
|
|
163
|
-
* change the outcome. Consumers must not layer their own retry on top - doing so either races the
|
|
164
|
-
* internal one or repeats a call that already cannot succeed, and delays the disable/back-off the
|
|
165
|
-
* rejection exists to trigger. The correct response to the first rejection is to stop writing.
|
|
166
|
-
* See README "Handling write failures".
|
|
167
|
-
*/
|
|
168
129
|
async writeToFile(type, content, overwrite) {
|
|
169
130
|
const path = this.getOptionForType(type, 'path');
|
|
170
131
|
const filenameTemplate = this.getOptionForType(type, 'filename');
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
await (overwrite ? fsp.writeFile : fsp.appendFile)(targetLog, content);
|
|
176
|
-
};
|
|
132
|
+
const resolvedName = this.resolveFilename(filenameTemplate, type);
|
|
133
|
+
const targetLog = `${path}${resolvedName}`;
|
|
134
|
+
const fileAction = overwrite ? fsp.writeFile : fsp.appendFile;
|
|
135
|
+
await this.validateFileAndDirectory(path, targetLog);
|
|
177
136
|
try {
|
|
178
|
-
await
|
|
179
|
-
// a warm cache can outlive its directory, so drop the entry and retry exactly once
|
|
180
|
-
if (!['ENOENT', 'EACCES', 'EPERM', 'EROFS'].includes(error?.code))
|
|
181
|
-
throw error;
|
|
182
|
-
this.directoryCache.delete(path);
|
|
183
|
-
return attempt();
|
|
184
|
-
});
|
|
137
|
+
await fileAction(targetLog, content);
|
|
185
138
|
}
|
|
186
139
|
catch (error) {
|
|
187
|
-
const { code
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* One structured stderr notice per failure episode: an emit on the first failure, silence while
|
|
201
|
-
* it keeps failing, one recovery emit on the next success. A null payload records a success.
|
|
202
|
-
*
|
|
203
|
-
* Keyed on the directory rather than the resolved target, matching `directoryCache`: every code
|
|
204
|
-
* in the retry allowlist is a directory-level condition, so keying on the target would strand an
|
|
205
|
-
* un-reaped entry - and silently drop its suppressed count - whenever a `{date}` template rotates
|
|
206
|
-
* away from a still-failing filename.
|
|
207
|
-
*/
|
|
208
|
-
noticeWriteResult(path, targetLog, payload) {
|
|
209
|
-
const failures = this.writeFailures.get(path) ?? 0;
|
|
210
|
-
if (payload) {
|
|
211
|
-
this.writeFailures.set(path, failures + 1);
|
|
212
|
-
if (!failures) {
|
|
213
|
-
this.emitNotice('error', 'log-write-failed', {
|
|
214
|
-
...payload,
|
|
215
|
-
suppressedCount: 0,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
else if (failures) {
|
|
220
|
-
this.writeFailures.delete(path);
|
|
221
|
-
this.emitNotice('warn', 'log-write-recovered', {
|
|
222
|
-
targetLog,
|
|
223
|
-
path,
|
|
224
|
-
suppressedCount: failures - 1,
|
|
225
|
-
});
|
|
140
|
+
const { code } = error;
|
|
141
|
+
if (!code || !recoverableWriteCodes.has(code))
|
|
142
|
+
throw error;
|
|
143
|
+
/*
|
|
144
|
+
* The cached directory may have been removed underneath a warm cache. Invalidate
|
|
145
|
+
* the entry and retry exactly once so a cache hit can never become a permanent
|
|
146
|
+
* silent write failure. The rejection is the caller's only failure signal.
|
|
147
|
+
*/
|
|
148
|
+
this.directoryCache.delete(path);
|
|
149
|
+
await this.validateFileAndDirectory(path, targetLog);
|
|
150
|
+
await fileAction(targetLog, content);
|
|
226
151
|
}
|
|
227
152
|
}
|
|
228
|
-
/**
|
|
229
|
-
* The write-failure path must never re-enter this logger, so notices go straight out through
|
|
230
|
-
* `console.error` as a single JSONL record, bypassing all of this package's formatting. Field
|
|
231
|
-
* order is the canonical one mandated by the framework logging schema, with the optional
|
|
232
|
-
* `schemaVersion` last.
|
|
233
|
-
*/
|
|
234
|
-
emitNotice(severity, event, payload) {
|
|
235
|
-
const ts = new Date().toISOString();
|
|
236
|
-
console.error(JSON.stringify({
|
|
237
|
-
ts,
|
|
238
|
-
surface: 'stonyx-logs',
|
|
239
|
-
sessionKey: null,
|
|
240
|
-
project: null,
|
|
241
|
-
severity,
|
|
242
|
-
event,
|
|
243
|
-
payload,
|
|
244
|
-
schemaVersion: 1,
|
|
245
|
-
}));
|
|
246
|
-
}
|
|
247
153
|
// resolves template variables in a filename string
|
|
248
154
|
resolveFilename(template, type) {
|
|
249
155
|
// default to '{type}.log' when no template is configured
|
|
@@ -265,20 +171,26 @@ export default class Log {
|
|
|
265
171
|
// sanitize: prevent path traversal and disallow directory separators
|
|
266
172
|
return resolved.replace(/\.\./g, '').replace(/[/\\]/g, '');
|
|
267
173
|
}
|
|
268
|
-
|
|
269
|
-
* Ensures the
|
|
270
|
-
*
|
|
271
|
-
*
|
|
174
|
+
/*
|
|
175
|
+
* Ensures the log directory exists, deduping concurrent and repeat calls onto a single
|
|
176
|
+
* mkdir per directory. No file bootstrap happens here: both write paths already create
|
|
177
|
+
* the file (appendFile opens 'a', writeFile opens 'w'), so a bootstrap write's only
|
|
178
|
+
* reachable effect was truncating a concurrent caller's content.
|
|
179
|
+
*
|
|
180
|
+
* targetLog is unused but retained for signature compatibility.
|
|
272
181
|
*/
|
|
273
182
|
async validateFileAndDirectory(path, targetLog) {
|
|
274
|
-
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
183
|
+
const cached = this.directoryCache.get(path);
|
|
184
|
+
if (cached)
|
|
185
|
+
return cached;
|
|
186
|
+
// cache the promise before awaiting so concurrent writers dedupe and none run early
|
|
187
|
+
const pending = fsp.mkdir(path, { recursive: true }).then(() => undefined);
|
|
188
|
+
this.directoryCache.set(path, pending);
|
|
189
|
+
// never cache a poisoned promise: drop the entry so the next write retries
|
|
190
|
+
pending.catch(() => {
|
|
191
|
+
if (this.directoryCache.get(path) === pending)
|
|
192
|
+
this.directoryCache.delete(path);
|
|
193
|
+
});
|
|
282
194
|
return pending;
|
|
283
195
|
}
|
|
284
196
|
// method to conditionally sanitize user configuration input
|