Skip to content
Source
Sign inSign up

logging.ts

268 lines8.4 KB

1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4import * as Schema from "effect/Schema";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Semaphore from "effect/Semaphore";
9​
10export interface RotatingFileSinkOptions {
11 readonly filePath: string;
12 readonly maxBytes: number;
13 readonly maxFiles: number;
14 readonly throwOnError?: boolean;
16​
17export class RotatingFileSinkConfigurationError extends Schema.TaggedError<RotatingFileSinkConfigurationError>()(
18 "RotatingFileSinkConfigurationError",
19 {
20 option: Schema.Literals(["maxBytes", "maxFiles"]),
21 received: Schema.Number,
22 minimum: Schema.Number,
23 },
24) {
25 override get message(): string {
26 return `${this.option} must be >= ${this.minimum} (received ${this.received})`;
27 }
29​
30export class RotatingFileSinkError extends Schema.TaggedError<RotatingFileSinkError>()(
31 "RotatingFileSinkError",
32 {
33 operation: Schema.Literals(["initialize", "read", "write", "rotate", "prune"]),
34 filePath: Schema.String,
35 cause: Schema.Defect(),
36 },
37) {
38 override get message(): string {
39 return `Failed to ${this.operation} rotating log file ${this.filePath}`;
40 }
42​
43const isRotatingFileSinkError = Schema.is(RotatingFileSinkError);
44​
45const isFileNotFoundError = (cause: unknown): cause is NodeJS.ErrnoException =>
46 cause instanceof Error && "code" in cause && cause.code === "ENOENT";
47​
48export class RotatingFileSink {
49 private readonly filePath: string;
50 private readonly maxBytes: number;
51 private readonly maxFiles: number;
52 private readonly throwOnError: boolean;
53 private currentSize = 0;
54​
55 constructor(options: RotatingFileSinkOptions) {
56 if (options.maxBytes < 1) {
57 throw new RotatingFileSinkConfigurationError({
58 option: "maxBytes",
59 received: options.maxBytes,
60 minimum: 1,
61 });
62 }
63 if (options.maxFiles < 1) {
64 throw new RotatingFileSinkConfigurationError({
65 option: "maxFiles",
66 received: options.maxFiles,
67 minimum: 1,
68 });
69 }
70​
71 this.filePath = options.filePath;
72 this.maxBytes = options.maxBytes;
73 this.maxFiles = options.maxFiles;
74 this.throwOnError = options.throwOnError ?? false;
75​
76 try {
77 NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true });
78 } catch (cause) {
79 throw new RotatingFileSinkError({
80 operation: "initialize",
81 filePath: this.filePath,
82 cause,
83 });
84 }
85 this.pruneOverflowBackups();
86 this.currentSize = this.readCurrentSize();
87 }
88​
89 write(chunk: string | Buffer): void {
90 const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
91 if (buffer.length === 0) return;
92​
93 try {
94 if (this.currentSize > 0 && this.currentSize + buffer.length > this.maxBytes) {
95 this.rotate();
96 }
97​
98 NodeFS.appendFileSync(this.filePath, buffer);
99 this.currentSize += buffer.length;
100 } catch (cause) {
101 if (isRotatingFileSinkError(cause)) {
102 throw cause;
103 }
104 if (this.throwOnError) {
105 throw new RotatingFileSinkError({
106 operation: "write",
107 filePath: this.filePath,
108 cause,
109 });
110 }
111 this.currentSize = this.readCurrentSize();
112 }
113 }
114​
115 private rotate(): void {
116 try {
117 const oldest = this.withSuffix(this.maxFiles);
118 if (NodeFS.existsSync(oldest)) {
119 NodeFS.rmSync(oldest, { force: true });
120 }
121​
122 for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
123 const source = this.withSuffix(index);
124 const target = this.withSuffix(index + 1);
125 if (NodeFS.existsSync(source)) {
126 NodeFS.renameSync(source, target);
127 }
128 }
129​
130 if (NodeFS.existsSync(this.filePath)) {
131 NodeFS.renameSync(this.filePath, this.withSuffix(1));
132 }
133​
134 this.currentSize = 0;
135 } catch (cause) {
136 if (this.throwOnError) {
137 throw new RotatingFileSinkError({
138 operation: "rotate",
139 filePath: this.filePath,
140 cause,
141 });
142 }
143 this.currentSize = this.readCurrentSize();
144 }
145 }
146​
147 private pruneOverflowBackups(): void {
148 try {
149 const dir = NodePath.dirname(this.filePath);
150 const baseName = NodePath.basename(this.filePath);
151 for (const entry of NodeFS.readdirSync(dir)) {
152 if (!entry.startsWith(`${baseName}.`)) continue;
153 const suffixText = entry.slice(baseName.length + 1);
154 const suffix = Number(suffixText);
155 if (!Number.isInteger(suffix) || String(suffix) !== suffixText || suffix <= this.maxFiles)
156 continue;
157 NodeFS.rmSync(NodePath.join(dir, entry), { force: true });
158 }
159 } catch (cause) {
160 if (this.throwOnError) {
161 throw new RotatingFileSinkError({
162 operation: "prune",
163 filePath: this.filePath,
164 cause,
165 });
166 }
167 }
168 }
169​
170 private readCurrentSize(): number {
171 try {
172 return NodeFS.statSync(this.filePath).size;
173 } catch (cause) {
174 if (isFileNotFoundError(cause)) {
175 return 0;
176 }
177 throw new RotatingFileSinkError({
178 operation: "read",
179 filePath: this.filePath,
180 cause,
181 });
182 }
183 }
184​
185 private withSuffix(index: number): string {
186 return `${this.filePath}.${index}`;
187 }
189​
190export const makeAsyncRotatingFileSink = Effect.fnUntraced(function* (
191 options: Omit<RotatingFileSinkOptions, "throwOnError">,
192) {
193 const fs = yield* FileSystem.FileSystem;
194 const path = yield* Path.Path;
195 if (options.maxBytes < 1)
196 return yield* new RotatingFileSinkConfigurationError({
197 option: "maxBytes",
198 received: options.maxBytes,
199 minimum: 1,
200 });
201 if (options.maxFiles < 1)
202 return yield* new RotatingFileSinkConfigurationError({
203 option: "maxFiles",
204 received: options.maxFiles,
205 minimum: 1,
206 });
207 const failure = (operation: RotatingFileSinkError["operation"]) => (cause: unknown) =>
208 new RotatingFileSinkError({ operation, filePath: options.filePath, cause });
209 const directory = path.dirname(options.filePath);
210 const basename = path.basename(options.filePath);
211 yield* fs
212 .makeDirectory(directory, { recursive: true })
213 .pipe(Effect.mapError(failure("initialize")));
214 yield* Effect.gen(function* () {
215 for (const entry of yield* fs.readDirectory(directory)) {
216 if (!entry.startsWith(`${basename}.`)) continue;
217 const suffixText = entry.slice(basename.length + 1);
218 const suffix = Number(suffixText);
219 if (!Number.isInteger(suffix) || String(suffix) !== suffixText || suffix <= options.maxFiles)
220 continue;
221 yield* fs.remove(path.join(directory, entry), { force: true });
222 }
223 }).pipe(Effect.mapError(failure("prune")));
224 const readSize = fs.stat(options.filePath).pipe(
225 Effect.map((stat) => Number(stat.size)),
226 Effect.catch((cause) =>
227 cause.reason._tag === "NotFound" ? Effect.succeed(0) : Effect.fail(failure("read")(cause)),
228 ),
229 );
230 let currentSize = yield* readSize;
231 const semaphore = yield* Semaphore.make(1);
232 const rotate = Effect.gen(function* () {
233 yield* fs.remove(`${options.filePath}.${options.maxFiles}`, { force: true });
234 for (let index = options.maxFiles - 1; index >= 1; index--) {
235 const source = `${options.filePath}.${index}`;
236 if (yield* fs.exists(source)) yield* fs.rename(source, `${options.filePath}.${index + 1}`);
237 }
238 if (yield* fs.exists(options.filePath))
239 yield* fs.rename(options.filePath, `${options.filePath}.1`);
240 currentSize = 0;
241 }).pipe(Effect.mapError(failure("rotate")));
242 const write = (chunk: string | Buffer) =>
243 semaphore
244 .withPermits(1)(
245 Effect.gen(function* () {
246 const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
247 if (buffer.length === 0) return;
248 if (currentSize > 0 && currentSize + buffer.length > options.maxBytes) yield* rotate;
249 yield* fs
250 .writeFile(options.filePath, buffer, { flag: "a" })
251 .pipe(Effect.mapError(failure("write")));
252 currentSize += buffer.length;
253 }).pipe(
254 Effect.tapError(() =>
255 readSize.pipe(
256 Effect.tap((size) =>
257 Effect.sync(() => {
258 currentSize = size;
259 }),
260 ),
261 Effect.ignore,
262 ),
263 ),
264 ),
265 )
266 .pipe(Effect.uninterruptible);
267 return { write };
268});