@stonyx/logs 1.0.1-beta.16 → 1.0.1-beta.18
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/dist/index.d.ts +8 -0
- package/dist/index.js +106 -17
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Color, { type ColorSetting } from './color.js';
|
|
2
|
+
export type Severity = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
|
2
3
|
export interface LogOptions {
|
|
3
4
|
logToFileByDefault: boolean;
|
|
4
5
|
logTimestamp: boolean;
|
|
@@ -13,7 +14,12 @@ export default class Log {
|
|
|
13
14
|
options: LogOptions;
|
|
14
15
|
color: Color;
|
|
15
16
|
typeOptions: Record<string, Partial<LogOptions>>;
|
|
17
|
+
directoryCache: Map<string, Promise<void>>;
|
|
18
|
+
writeFailures: Map<string, number>;
|
|
16
19
|
[key: string]: unknown;
|
|
20
|
+
info: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
|
|
21
|
+
warn: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
|
|
22
|
+
error: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
|
|
17
23
|
constructor(options?: Partial<LogOptions>);
|
|
18
24
|
defineType(type: string, setting: ColorSetting, options?: Partial<LogOptions> | null): void;
|
|
19
25
|
createConvenienceMethod(type: string): void;
|
|
@@ -23,6 +29,8 @@ export default class Log {
|
|
|
23
29
|
log(content: string, type: string, logToFile: boolean, overwrite: boolean): Promise<void>;
|
|
24
30
|
debug(content: unknown, logToFile?: boolean, overwrite?: boolean): Promise<void>;
|
|
25
31
|
writeToFile(type: string, content: string, overwrite: boolean): Promise<void>;
|
|
32
|
+
noticeWriteResult(path: string, targetLog: string, payload: Record<string, unknown> | null): void;
|
|
33
|
+
emitNotice(severity: Severity, event: string, payload: Record<string, unknown>): void;
|
|
26
34
|
resolveFilename(template: string, type: string): string;
|
|
27
35
|
validateFileAndDirectory(path: string, targetLog: string): Promise<void>;
|
|
28
36
|
sanitizePath(path: string): string;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { promises as fsp } from 'fs';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { hostname } from 'os';
|
|
4
4
|
import projectPath from 'path';
|
|
@@ -23,6 +23,10 @@ export default class Log {
|
|
|
23
23
|
options;
|
|
24
24
|
color;
|
|
25
25
|
typeOptions = {};
|
|
26
|
+
// resolved directory -> in-flight or settled mkdir; instance-scoped so it cannot outlive its directory
|
|
27
|
+
directoryCache = new Map();
|
|
28
|
+
// resolved directory -> consecutive write failures, used to dedupe the operator notice
|
|
29
|
+
writeFailures = new Map();
|
|
26
30
|
constructor(options = {}) {
|
|
27
31
|
const merged = {
|
|
28
32
|
...defaultOptions,
|
|
@@ -89,7 +93,7 @@ export default class Log {
|
|
|
89
93
|
chalk() {
|
|
90
94
|
return this.color.getChalkInstance();
|
|
91
95
|
}
|
|
92
|
-
// logs to console, and conditionally to file
|
|
96
|
+
// logs to console, and conditionally to file; propagates any writeToFile rejection to the caller
|
|
93
97
|
async log(content, type, logToFile, overwrite) {
|
|
94
98
|
const logTimestamp = this.getOptionForType(type, 'logTimestamp');
|
|
95
99
|
const timestamp = `[${new Date().toLocaleString('en-US')}]`;
|
|
@@ -108,21 +112,101 @@ export default class Log {
|
|
|
108
112
|
return;
|
|
109
113
|
await this.writeToFile(type, `${timestamp} ${content}\n`, overwrite);
|
|
110
114
|
}
|
|
111
|
-
// direct hardcoded debug method (
|
|
115
|
+
// direct hardcoded debug method (limited file logging); propagates any writeToFile rejection
|
|
112
116
|
async debug(content, logToFile = false, overwrite = true) {
|
|
113
117
|
console.dir(content, { depth: 6 }); // eslint-disable-line no-console
|
|
114
118
|
if (!logToFile)
|
|
115
119
|
return;
|
|
116
120
|
await this.writeToFile('debug', JSON.stringify(content, null, 2), overwrite);
|
|
117
121
|
}
|
|
122
|
+
/*
|
|
123
|
+
* Writes `content` to the resolved target for `type`, creating the target's directory on first
|
|
124
|
+
* use for that directory.
|
|
125
|
+
*
|
|
126
|
+
* The returned promise rejecting with the underlying `NodeJS.ErrnoException` (with `err.code`
|
|
127
|
+
* preserved) is the sole failure signal - callers that care must handle it. The structured
|
|
128
|
+
* stderr notice emitted alongside a failure is informational only and is deduped per episode.
|
|
129
|
+
*
|
|
130
|
+
* On `ENOENT`, `EACCES`, `EPERM` or `EROFS` the cached directory entry is dropped and the write
|
|
131
|
+
* is retried exactly once; every other code rejects on the first attempt without a retry.
|
|
132
|
+
*/
|
|
118
133
|
async writeToFile(type, content, overwrite) {
|
|
119
134
|
const path = this.getOptionForType(type, 'path');
|
|
120
135
|
const filenameTemplate = this.getOptionForType(type, 'filename');
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
const targetLog = `${path}${this.resolveFilename(filenameTemplate, type)}`;
|
|
137
|
+
const cached = this.directoryCache.has(path);
|
|
138
|
+
const attempt = async () => {
|
|
139
|
+
await this.validateFileAndDirectory(path, targetLog);
|
|
140
|
+
await (overwrite ? fsp.writeFile : fsp.appendFile)(targetLog, content);
|
|
141
|
+
};
|
|
142
|
+
try {
|
|
143
|
+
await attempt().catch(async (error) => {
|
|
144
|
+
// a warm cache can outlive its directory, so drop the entry and retry exactly once
|
|
145
|
+
if (!['ENOENT', 'EACCES', 'EPERM', 'EROFS'].includes(error?.code))
|
|
146
|
+
throw error;
|
|
147
|
+
this.directoryCache.delete(path);
|
|
148
|
+
return attempt();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
const { code, syscall } = error;
|
|
153
|
+
this.noticeWriteResult(path, targetLog, {
|
|
154
|
+
targetLog,
|
|
155
|
+
path,
|
|
156
|
+
syscall,
|
|
157
|
+
code,
|
|
158
|
+
cached,
|
|
159
|
+
});
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
this.noticeWriteResult(path, targetLog, null);
|
|
163
|
+
}
|
|
164
|
+
/*
|
|
165
|
+
* One structured stderr notice per failure episode: an emit on the first failure, silence while
|
|
166
|
+
* it keeps failing, one recovery emit on the next success. A null payload records a success.
|
|
167
|
+
*
|
|
168
|
+
* Keyed on the directory rather than the resolved target, matching `directoryCache`: every code
|
|
169
|
+
* in the retry allowlist is a directory-level condition, so keying on the target would strand an
|
|
170
|
+
* un-reaped entry - and silently drop its suppressed count - whenever a `{date}` template rotates
|
|
171
|
+
* away from a still-failing filename.
|
|
172
|
+
*/
|
|
173
|
+
noticeWriteResult(path, targetLog, payload) {
|
|
174
|
+
const failures = this.writeFailures.get(path) ?? 0;
|
|
175
|
+
if (payload) {
|
|
176
|
+
this.writeFailures.set(path, failures + 1);
|
|
177
|
+
if (!failures) {
|
|
178
|
+
this.emitNotice('error', 'log-write-failed', {
|
|
179
|
+
...payload,
|
|
180
|
+
suppressedCount: 0,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (failures) {
|
|
185
|
+
this.writeFailures.delete(path);
|
|
186
|
+
this.emitNotice('warn', 'log-write-recovered', {
|
|
187
|
+
targetLog,
|
|
188
|
+
path,
|
|
189
|
+
suppressedCount: failures - 1,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/*
|
|
194
|
+
* The write-failure path must never re-enter this logger, so notices go straight to stderr as a
|
|
195
|
+
* single JSONL record. Field order is the canonical one mandated by the framework logging
|
|
196
|
+
* schema, with the optional `schemaVersion` last.
|
|
197
|
+
*/
|
|
198
|
+
emitNotice(severity, event, payload) {
|
|
199
|
+
const ts = new Date().toISOString();
|
|
200
|
+
console.error(JSON.stringify({
|
|
201
|
+
ts,
|
|
202
|
+
surface: 'stonyx-logs',
|
|
203
|
+
sessionKey: null,
|
|
204
|
+
project: null,
|
|
205
|
+
severity,
|
|
206
|
+
event,
|
|
207
|
+
payload,
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
}));
|
|
126
210
|
}
|
|
127
211
|
// resolves template variables in a filename string
|
|
128
212
|
resolveFilename(template, type) {
|
|
@@ -145,16 +229,21 @@ export default class Log {
|
|
|
145
229
|
// sanitize: prevent path traversal and disallow directory separators
|
|
146
230
|
return resolved.replace(/\.\./g, '').replace(/[/\\]/g, '');
|
|
147
231
|
}
|
|
148
|
-
|
|
232
|
+
/*
|
|
233
|
+
* Ensures the target's directory exists. Both write paths auto-create the file itself, so no
|
|
234
|
+
* bootstrap write is needed. `targetLog` is unused but retained for signature compatibility:
|
|
235
|
+
* resolveFilename strips separators, so the only cached invariant is the directory.
|
|
236
|
+
*/
|
|
149
237
|
async validateFileAndDirectory(path, targetLog) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
fsp.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
238
|
+
let pending = this.directoryCache.get(path);
|
|
239
|
+
if (!pending) {
|
|
240
|
+
// cache the promise before awaiting: a flag here would let a concurrent write append first
|
|
241
|
+
pending = fsp.mkdir(path, { recursive: true }).then(() => undefined);
|
|
242
|
+
this.directoryCache.set(path, pending);
|
|
243
|
+
// never keep a poisoned entry - the next write retries from scratch
|
|
244
|
+
pending.catch(() => this.directoryCache.delete(path));
|
|
245
|
+
}
|
|
246
|
+
return pending;
|
|
158
247
|
}
|
|
159
248
|
// method to conditionally sanitize user configuration input
|
|
160
249
|
sanitizePath(path) {
|