sproutboat 0.52.1 → 0.53.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/CHANGELOG.md +28 -0
- package/README.md +27 -11
- package/SURFACE.md +6 -5
- package/package.json +7 -7
- package/src/dev.ts +24 -24
- package/src/env-file.ts +248 -0
- package/src/init-package.ts +99 -0
- package/src/main.ts +116 -10
- package/src/run.ts +168 -0
- package/src/surface.ts +16 -5
- package/src/tail-log.ts +471 -0
- package/src/tail.ts +40 -3
package/src/tail-log.ts
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sproutboat tail --file <log>`: follow the structured records a standalone
|
|
3
|
+
* binary writes when it runs with `SB_LOG=json` (#268). No control plane and
|
|
4
|
+
* no login are involved; the log is whatever the operator already keeps:
|
|
5
|
+
*
|
|
6
|
+
* SB_LOG=json ./app 2>> /var/log/app.jsonl
|
|
7
|
+
* sproutboat tail --file /var/log/app.jsonl
|
|
8
|
+
* journalctl -u app -f -o cat | sproutboat tail --file -
|
|
9
|
+
*
|
|
10
|
+
* Records are `request`, `error` and `dropped` lines of version 1. Anything
|
|
11
|
+
* else in the file (the app's own console output, a startup banner, a record
|
|
12
|
+
* of a newer version) is skipped. Output stays bounded and safe to pipe:
|
|
13
|
+
* only known fields are kept and cut to fixed lengths, whatever the file
|
|
14
|
+
* holds, so a hostile or oversized line cannot grow memory, inject terminal
|
|
15
|
+
* escapes or add fields to `--format json` output.
|
|
16
|
+
*
|
|
17
|
+
* Reconnect behavior while following a file: it is polled, and a missing file
|
|
18
|
+
* is waited for; a file replaced by rotation (a new inode) or truncated in
|
|
19
|
+
* place is reopened from its start. Following ends on SIGINT or SIGTERM
|
|
20
|
+
* (exit 0). With `-` (stdin) it ends when the input does.
|
|
21
|
+
*/
|
|
22
|
+
import { open, stat, type FileHandle } from "node:fs/promises";
|
|
23
|
+
import { isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
|
|
24
|
+
import { amber, bold, dim, leaf, rose } from "./style";
|
|
25
|
+
|
|
26
|
+
export type LogRequest = {
|
|
27
|
+
type: "request";
|
|
28
|
+
id: string;
|
|
29
|
+
timestamp: number;
|
|
30
|
+
method: string;
|
|
31
|
+
path: string;
|
|
32
|
+
status: number;
|
|
33
|
+
durationMs: number;
|
|
34
|
+
outcome: string;
|
|
35
|
+
};
|
|
36
|
+
export type LogError = {
|
|
37
|
+
type: "error";
|
|
38
|
+
id: string | null;
|
|
39
|
+
timestamp: number;
|
|
40
|
+
source: string;
|
|
41
|
+
name: string;
|
|
42
|
+
message: string;
|
|
43
|
+
stack?: string;
|
|
44
|
+
};
|
|
45
|
+
export type LogDropped = { type: "dropped"; timestamp: number; count: number };
|
|
46
|
+
export type LogRecord = LogRequest | LogError | LogDropped;
|
|
47
|
+
|
|
48
|
+
const MAX_ID = 64;
|
|
49
|
+
const MAX_METHOD = 16;
|
|
50
|
+
const MAX_PATH = 256;
|
|
51
|
+
const MAX_SOURCE = 40;
|
|
52
|
+
const MAX_NAME = 100;
|
|
53
|
+
const MAX_MESSAGE = 1000;
|
|
54
|
+
const MAX_STACK = 2000;
|
|
55
|
+
/** The longest line read; a longer one is skipped, never held in memory. */
|
|
56
|
+
export const DEFAULT_MAX_LINE_BYTES = 64 * 1024;
|
|
57
|
+
/** How much of the end of a file is looked at to find the last lines. */
|
|
58
|
+
const SNAPSHOT_WINDOW_BYTES = 256 * 1024;
|
|
59
|
+
const READ_CHUNK_BYTES = 256 * 1024;
|
|
60
|
+
export const MAX_SNAPSHOT_LINES = 100_000;
|
|
61
|
+
|
|
62
|
+
const cut = (text: string, max: number): string => (text.length > max ? text.slice(0, max) : text);
|
|
63
|
+
const count = (value: JsonValue | undefined): number | undefined =>
|
|
64
|
+
isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
65
|
+
|
|
66
|
+
/** One log line as a record, or undefined when it is not a version 1 record. */
|
|
67
|
+
export function parseLogLine(line: string): LogRecord | undefined {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (!trimmed.startsWith("{")) return undefined;
|
|
70
|
+
let record: JsonObject | undefined;
|
|
71
|
+
try {
|
|
72
|
+
record = jsonObject(parseJsonValue(trimmed));
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
if (!record || record.v !== 1) return undefined;
|
|
77
|
+
const timestamp = count(record.timestamp);
|
|
78
|
+
if (timestamp === undefined) return undefined;
|
|
79
|
+
if (record.type === "request") {
|
|
80
|
+
const status = count(record.status);
|
|
81
|
+
if (!isString(record.id) || !isString(record.method) || !isString(record.path) || status === undefined)
|
|
82
|
+
return undefined;
|
|
83
|
+
return {
|
|
84
|
+
type: "request",
|
|
85
|
+
id: cut(record.id, MAX_ID),
|
|
86
|
+
timestamp,
|
|
87
|
+
method: cut(record.method, MAX_METHOD),
|
|
88
|
+
path: cut(record.path, MAX_PATH),
|
|
89
|
+
status,
|
|
90
|
+
durationMs: count(record.durationMs) ?? 0,
|
|
91
|
+
outcome: isString(record.outcome) ? cut(record.outcome, MAX_NAME) : "ok",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (record.type === "error") {
|
|
95
|
+
if (!isString(record.source) || !isString(record.name) || !isString(record.message)) return undefined;
|
|
96
|
+
const error: LogError = {
|
|
97
|
+
type: "error",
|
|
98
|
+
id: isString(record.id) ? cut(record.id, MAX_ID) : null,
|
|
99
|
+
timestamp,
|
|
100
|
+
source: cut(record.source, MAX_SOURCE),
|
|
101
|
+
name: cut(record.name, MAX_NAME),
|
|
102
|
+
message: cut(record.message, MAX_MESSAGE),
|
|
103
|
+
};
|
|
104
|
+
if (isString(record.stack)) error.stack = cut(record.stack, MAX_STACK);
|
|
105
|
+
return error;
|
|
106
|
+
}
|
|
107
|
+
if (record.type === "dropped") return { type: "dropped", timestamp, count: count(record.count) ?? 0 };
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type StatusMatcher =
|
|
112
|
+
| { kind: "ok" }
|
|
113
|
+
| { kind: "error" }
|
|
114
|
+
| { kind: "class"; digit: number }
|
|
115
|
+
| { kind: "code"; code: number };
|
|
116
|
+
|
|
117
|
+
const MAX_PENDING_ERRORS = 200;
|
|
118
|
+
|
|
119
|
+
export type LogFilterInput = { status: string[]; method: string[]; search: string | undefined };
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `--status`, `--method` and `--search` for a log. Status and method describe a
|
|
123
|
+
* request, so they select request records; an error follows the request it was
|
|
124
|
+
* raised in (shown when that request was), and an error with no request (an
|
|
125
|
+
* alarm, a queue, a rejected promise) is always shown. The meanings match the
|
|
126
|
+
* live tail: `ok` is a success below 400, `error` is a 5xx or a thrown handler.
|
|
127
|
+
*/
|
|
128
|
+
export class LogFilter {
|
|
129
|
+
private readonly status: StatusMatcher[];
|
|
130
|
+
private readonly method: string[];
|
|
131
|
+
private readonly search: string | undefined;
|
|
132
|
+
private readonly shown = new Map<string, boolean>();
|
|
133
|
+
/** Errors whose request record has not arrived yet: the runtime writes an error before its request's record. */
|
|
134
|
+
private readonly pending = new Map<string, LogError>();
|
|
135
|
+
|
|
136
|
+
constructor(input: LogFilterInput) {
|
|
137
|
+
this.status = input.status.map((value): StatusMatcher => {
|
|
138
|
+
const lower = value.toLowerCase();
|
|
139
|
+
if (lower === "ok" || lower === "error") return { kind: lower };
|
|
140
|
+
if (/^[1-5]xx$/.test(lower)) return { kind: "class", digit: Number(lower[0]) };
|
|
141
|
+
return { kind: "code", code: Number(lower) };
|
|
142
|
+
});
|
|
143
|
+
this.method = input.method.map((value) => value.toUpperCase());
|
|
144
|
+
this.search = input.search?.toLowerCase();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The records to print for `record`, in order. An error raised while answering a request is written
|
|
149
|
+
* before that request's record, so under a status or method filter it waits for the request (up to
|
|
150
|
+
* 200 at a time, oldest dropped) and is printed just before it, or discarded with it.
|
|
151
|
+
*/
|
|
152
|
+
accept(record: LogRecord): LogRecord[] {
|
|
153
|
+
if (record.type === "error" && record.id !== null && !this.shown.has(record.id)) {
|
|
154
|
+
const filtered = this.status.length > 0 || this.method.length > 0;
|
|
155
|
+
if (filtered) {
|
|
156
|
+
if (this.search !== undefined && !this.haystack(record).includes(this.search)) return [];
|
|
157
|
+
if (this.pending.size >= MAX_PENDING_ERRORS) {
|
|
158
|
+
const oldest = this.pending.keys().next();
|
|
159
|
+
if (!oldest.done) this.pending.delete(oldest.value);
|
|
160
|
+
}
|
|
161
|
+
this.pending.set(record.id, record);
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const passes = this.matches(record);
|
|
166
|
+
if (record.type !== "request") return passes ? [record] : [];
|
|
167
|
+
const waiting = this.pending.get(record.id);
|
|
168
|
+
this.pending.delete(record.id);
|
|
169
|
+
return passes ? (waiting ? [waiting, record] : [record]) : [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
matches(record: LogRecord): boolean {
|
|
173
|
+
if (record.type === "dropped") return true;
|
|
174
|
+
const searched = this.search === undefined || this.haystack(record).includes(this.search);
|
|
175
|
+
if (record.type === "request") {
|
|
176
|
+
const passes = searched && this.requestMatches(record);
|
|
177
|
+
this.remember(record.id, passes);
|
|
178
|
+
return passes;
|
|
179
|
+
}
|
|
180
|
+
if (!searched) return false;
|
|
181
|
+
if (record.id === null) return true;
|
|
182
|
+
// An error whose request is unknown (before the window, or filtered by search only) follows the filters it can.
|
|
183
|
+
return this.shown.get(record.id) ?? (this.status.length === 0 && this.method.length === 0);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private requestMatches(record: LogRequest): boolean {
|
|
187
|
+
if (this.method.length > 0 && !this.method.includes(record.method.toUpperCase())) return false;
|
|
188
|
+
if (this.status.length === 0) return true;
|
|
189
|
+
return this.status.some((matcher) => {
|
|
190
|
+
switch (matcher.kind) {
|
|
191
|
+
case "ok":
|
|
192
|
+
return record.outcome === "ok" && record.status < 400;
|
|
193
|
+
case "error":
|
|
194
|
+
return record.status >= 500 || record.outcome !== "ok";
|
|
195
|
+
case "code":
|
|
196
|
+
return record.status === matcher.code;
|
|
197
|
+
case "class":
|
|
198
|
+
return Math.floor(record.status / 100) === matcher.digit;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private haystack(record: LogRequest | LogError): string {
|
|
204
|
+
return (
|
|
205
|
+
record.type === "request"
|
|
206
|
+
? `${record.method} ${record.path}`
|
|
207
|
+
: `${record.source} ${record.name} ${record.message}`
|
|
208
|
+
).toLowerCase();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private remember(id: string, shown: boolean): void {
|
|
212
|
+
// Bounded: only the most recent requests are remembered.
|
|
213
|
+
if (this.shown.size >= 2000) {
|
|
214
|
+
const oldest = this.shown.keys().next();
|
|
215
|
+
if (!oldest.done) this.shown.delete(oldest.value);
|
|
216
|
+
}
|
|
217
|
+
this.shown.set(id, shown);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Newlines and tabs become spaces and other control characters "?", so a path or message cannot move the cursor or recolor the terminal. */
|
|
222
|
+
const printable = (text: string): string => text.replace(/[\n\t]/g, " ").replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
|
|
223
|
+
|
|
224
|
+
const clock = (ms: number): string => {
|
|
225
|
+
const date = new Date(ms);
|
|
226
|
+
const pad = (n: number, width = 2) => String(n).padStart(width, "0");
|
|
227
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
function statusColor(status: number, text: string): string {
|
|
231
|
+
if (status >= 500) return rose(text);
|
|
232
|
+
if (status >= 400) return amber(text);
|
|
233
|
+
if (status >= 300) return dim(text);
|
|
234
|
+
return leaf(text);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One pretty line for a record. */
|
|
238
|
+
export function formatLogRecord(record: LogRecord): string {
|
|
239
|
+
const time = dim(clock(record.timestamp));
|
|
240
|
+
if (record.type === "dropped") {
|
|
241
|
+
return `${time} ${amber(`! ${record.count} records dropped (the app was writing over its rate limit)`)}`;
|
|
242
|
+
}
|
|
243
|
+
if (record.type === "request") {
|
|
244
|
+
const outcome = record.outcome === "ok" ? dim("ok") : rose(printable(record.outcome));
|
|
245
|
+
return `${time} ${bold(printable(record.method).padEnd(6))} ${printable(record.path)} ${statusColor(record.status, String(record.status))} ${outcome} ${dim(`${record.durationMs}ms ${printable(record.id)}`)}`;
|
|
246
|
+
}
|
|
247
|
+
const message = printable(record.message);
|
|
248
|
+
const id = record.id === null ? "" : ` ${dim(printable(record.id))}`;
|
|
249
|
+
return `${time} ${rose(`✗ [${printable(record.source)}] ${printable(record.name)}: ${message}`)}${id}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type LogSinkOptions = {
|
|
253
|
+
format: "pretty" | "json";
|
|
254
|
+
filter: LogFilter;
|
|
255
|
+
signal: AbortSignal;
|
|
256
|
+
stdout: (text: string) => void;
|
|
257
|
+
stderr: (text: string) => void;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export type LogTailOptions = LogSinkOptions & {
|
|
261
|
+
path: string;
|
|
262
|
+
/** How many of the file's last matching lines to print first. */
|
|
263
|
+
lines: number;
|
|
264
|
+
/** Keep following after the snapshot. */
|
|
265
|
+
follow: boolean;
|
|
266
|
+
/** Poll interval of a followed file. */
|
|
267
|
+
pollMs?: number;
|
|
268
|
+
maxLineBytes?: number;
|
|
269
|
+
/** Called once the file is open and its snapshot is printed: anything appended after this is followed. */
|
|
270
|
+
onReady?: () => void;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** Turns lines into output. One per run, so a skipped-line count covers the whole run. */
|
|
274
|
+
class LogPrinter {
|
|
275
|
+
private skipped = 0;
|
|
276
|
+
private skippedLimit = DEFAULT_MAX_LINE_BYTES;
|
|
277
|
+
constructor(private readonly options: LogSinkOptions) {}
|
|
278
|
+
|
|
279
|
+
line(text: string): void {
|
|
280
|
+
const record = parseLogLine(text);
|
|
281
|
+
if (!record) return;
|
|
282
|
+
for (const out of this.options.filter.accept(record)) this.emit(out);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
emit(record: LogRecord): void {
|
|
286
|
+
// The record version rides along in JSON output, as it does in the log.
|
|
287
|
+
const { type, ...fields } = record;
|
|
288
|
+
const json = JSON.stringify({ type, v: 1, ...fields });
|
|
289
|
+
this.options.stdout(`${this.options.format === "json" ? json : formatLogRecord(record)}\n`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
skipLine(limit: number): void {
|
|
293
|
+
this.skipped += 1;
|
|
294
|
+
this.skippedLimit = limit;
|
|
295
|
+
this.options.stderr(
|
|
296
|
+
`${amber(`! ${this.skipped} line${this.skipped === 1 ? "" : "s"} over ${limit} bytes skipped`)}\n`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Splits bytes into lines of at most `maxBytes`; a longer line is skipped to its end. */
|
|
302
|
+
class LineSplitter {
|
|
303
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
304
|
+
private skipping = false;
|
|
305
|
+
constructor(
|
|
306
|
+
private readonly maxBytes: number,
|
|
307
|
+
private readonly onLine: (line: string) => void,
|
|
308
|
+
private readonly onSkip: () => void,
|
|
309
|
+
) {}
|
|
310
|
+
|
|
311
|
+
push(chunk: Uint8Array): void {
|
|
312
|
+
let data: Buffer = this.buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buffer, chunk]);
|
|
313
|
+
this.buffer = Buffer.alloc(0);
|
|
314
|
+
for (;;) {
|
|
315
|
+
const newline = data.indexOf(10);
|
|
316
|
+
if (newline < 0) break;
|
|
317
|
+
const line = data.subarray(0, newline);
|
|
318
|
+
data = data.subarray(newline + 1);
|
|
319
|
+
if (this.skipping) this.skipping = false;
|
|
320
|
+
else if (line.length > this.maxBytes) this.onSkip();
|
|
321
|
+
else this.onLine(line.toString("utf8"));
|
|
322
|
+
}
|
|
323
|
+
if (this.skipping) return;
|
|
324
|
+
if (data.length > this.maxBytes) {
|
|
325
|
+
this.skipping = true;
|
|
326
|
+
this.onSkip();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
this.buffer = Buffer.from(data);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Input ended without a final newline: a complete record is still a record. */
|
|
333
|
+
flush(): void {
|
|
334
|
+
if (!this.skipping && this.buffer.length > 0) this.onLine(this.buffer.toString("utf8"));
|
|
335
|
+
this.buffer = Buffer.alloc(0);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Records from a stream (stdin) until it ends or the signal aborts. */
|
|
340
|
+
export async function readLogStream(body: ReadableStream<Uint8Array>, options: LogSinkOptions): Promise<void> {
|
|
341
|
+
const printer = new LogPrinter(options);
|
|
342
|
+
const splitter = new LineSplitter(
|
|
343
|
+
DEFAULT_MAX_LINE_BYTES,
|
|
344
|
+
(line) => printer.line(line),
|
|
345
|
+
() => printer.skipLine(DEFAULT_MAX_LINE_BYTES),
|
|
346
|
+
);
|
|
347
|
+
const reader = body.getReader();
|
|
348
|
+
const onAbort = () => void reader.cancel().catch(() => {});
|
|
349
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
350
|
+
try {
|
|
351
|
+
for (;;) {
|
|
352
|
+
const next = await reader.read();
|
|
353
|
+
if (next.done) break;
|
|
354
|
+
splitter.push(next.value);
|
|
355
|
+
}
|
|
356
|
+
if (!options.signal.aborted) splitter.flush();
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (!options.signal.aborted) throw error;
|
|
359
|
+
} finally {
|
|
360
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const sleep = (ms: number, signal: AbortSignal) =>
|
|
365
|
+
new Promise<void>((resolve) => {
|
|
366
|
+
const timer = setTimeout(resolve, ms);
|
|
367
|
+
signal.addEventListener(
|
|
368
|
+
"abort",
|
|
369
|
+
() => {
|
|
370
|
+
clearTimeout(timer);
|
|
371
|
+
resolve();
|
|
372
|
+
},
|
|
373
|
+
{ once: true },
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const isMissing = (error: Error): boolean => "code" in error && error.code === "ENOENT";
|
|
378
|
+
|
|
379
|
+
/** The last `lines` matching records among the final complete lines of the file's last 256 KiB. */
|
|
380
|
+
async function snapshot(handle: FileHandle, size: number, printer: LogPrinter, options: LogTailOptions): Promise<void> {
|
|
381
|
+
if (options.lines === 0 || size === 0) return;
|
|
382
|
+
const start = Math.max(0, size - SNAPSHOT_WINDOW_BYTES);
|
|
383
|
+
const buffer = Buffer.alloc(size - start);
|
|
384
|
+
await handle.read(buffer, 0, buffer.length, start);
|
|
385
|
+
let text = buffer.toString("utf8");
|
|
386
|
+
// The window can begin mid-line: drop that first fragment.
|
|
387
|
+
if (start > 0) text = text.slice(text.indexOf("\n") + 1);
|
|
388
|
+
const picked: LogRecord[] = [];
|
|
389
|
+
for (const line of text.split("\n")) {
|
|
390
|
+
if (Buffer.byteLength(line) > (options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES)) continue;
|
|
391
|
+
const record = parseLogLine(line);
|
|
392
|
+
if (record) picked.push(...options.filter.accept(record));
|
|
393
|
+
}
|
|
394
|
+
for (const record of picked.slice(-options.lines)) printer.emit(record);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Print the file's last lines, then (unless `follow` is false) new records as
|
|
399
|
+
* they are appended, until the signal aborts.
|
|
400
|
+
*/
|
|
401
|
+
export async function followLog(options: LogTailOptions): Promise<void> {
|
|
402
|
+
const printer = new LogPrinter(options);
|
|
403
|
+
const pollMs = options.pollMs ?? 250;
|
|
404
|
+
const maxLine = options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
|
|
405
|
+
let announcedWait = false;
|
|
406
|
+
let first = true;
|
|
407
|
+
|
|
408
|
+
while (!options.signal.aborted) {
|
|
409
|
+
let handle: FileHandle;
|
|
410
|
+
try {
|
|
411
|
+
handle = await open(options.path, "r");
|
|
412
|
+
} catch (error) {
|
|
413
|
+
if (!(error instanceof Error) || !isMissing(error)) throw error;
|
|
414
|
+
if (!options.follow) throw new Error(`no such log file: ${options.path}`);
|
|
415
|
+
if (!announcedWait) options.stderr(`${dim(`waiting for ${options.path}`)}\n`);
|
|
416
|
+
announcedWait = true;
|
|
417
|
+
await sleep(pollMs, options.signal);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const opened = await handle.stat();
|
|
422
|
+
let offset = 0;
|
|
423
|
+
const splitter = new LineSplitter(
|
|
424
|
+
maxLine,
|
|
425
|
+
(line) => printer.line(line),
|
|
426
|
+
() => printer.skipLine(maxLine),
|
|
427
|
+
);
|
|
428
|
+
// A file that was not there at the start is read from its first byte: everything in it is new.
|
|
429
|
+
if (first && !announcedWait) {
|
|
430
|
+
await snapshot(handle, opened.size, printer, options);
|
|
431
|
+
offset = opened.size;
|
|
432
|
+
}
|
|
433
|
+
first = false;
|
|
434
|
+
options.onReady?.();
|
|
435
|
+
if (!options.follow) return;
|
|
436
|
+
// On a reopened file (rotation, truncation, a late start) everything in it is new.
|
|
437
|
+
for (;;) {
|
|
438
|
+
const chunk = Buffer.alloc(READ_CHUNK_BYTES);
|
|
439
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, offset);
|
|
440
|
+
if (bytesRead > 0) {
|
|
441
|
+
offset += bytesRead;
|
|
442
|
+
splitter.push(chunk.subarray(0, bytesRead));
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (options.signal.aborted) return;
|
|
446
|
+
await sleep(pollMs, options.signal);
|
|
447
|
+
if (options.signal.aborted) return;
|
|
448
|
+
let current;
|
|
449
|
+
try {
|
|
450
|
+
current = await stat(options.path);
|
|
451
|
+
} catch {
|
|
452
|
+
// Removed while open: wait for the next one.
|
|
453
|
+
options.stderr(`${dim(`${options.path} went away, waiting for it`)}\n`);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
if (current.ino !== opened.ino || current.dev !== opened.dev) {
|
|
457
|
+
// Drain the old file's last bytes first (the loop above ran to EOF just now), then switch.
|
|
458
|
+
options.stderr(`${dim("log rotated, following the new file")}\n`);
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
if (current.size < offset) {
|
|
462
|
+
options.stderr(`${dim("log truncated, reading from its start")}\n`);
|
|
463
|
+
offset = 0;
|
|
464
|
+
splitter.flush();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} finally {
|
|
468
|
+
await handle.close();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
package/src/tail.ts
CHANGED
|
@@ -22,6 +22,12 @@ export type TailArgs = {
|
|
|
22
22
|
samplingRate: string | undefined;
|
|
23
23
|
search: string | undefined;
|
|
24
24
|
outcome: string[];
|
|
25
|
+
/** #268: follow a standalone binary's SB_LOG=json records in this file ("-" for stdin) instead of the control plane. */
|
|
26
|
+
file: string | undefined;
|
|
27
|
+
/** With --file: how many of the last matching records to print before following. */
|
|
28
|
+
lines: number;
|
|
29
|
+
/** With --file: false prints the snapshot and exits. */
|
|
30
|
+
follow: boolean;
|
|
25
31
|
};
|
|
26
32
|
|
|
27
33
|
export type TailLog = { level: string; message: JsonValue[]; timestamp: number };
|
|
@@ -47,9 +53,19 @@ export type TailFrame =
|
|
|
47
53
|
| { type: "closed"; reason: string };
|
|
48
54
|
|
|
49
55
|
export const TAIL_USAGE =
|
|
50
|
-
"tail [project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate 0.1] [--search <text>] [--outcome <name>] | tail --recent | tail --sprout";
|
|
56
|
+
"tail [project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate 0.1] [--search <text>] [--outcome <name>] | tail --recent | tail --sprout | tail --file <log|-> [--lines <n>] [--no-follow] [--format pretty|json] [--status <s>] [--method <m>] [--search <text>]";
|
|
51
57
|
|
|
52
|
-
const VALUE_FLAGS = [
|
|
58
|
+
const VALUE_FLAGS = [
|
|
59
|
+
"--format",
|
|
60
|
+
"--status",
|
|
61
|
+
"--method",
|
|
62
|
+
"--sampling-rate",
|
|
63
|
+
"--search",
|
|
64
|
+
"--outcome",
|
|
65
|
+
"--file",
|
|
66
|
+
"--lines",
|
|
67
|
+
] as const;
|
|
68
|
+
const MAX_LINES = 100_000;
|
|
53
69
|
|
|
54
70
|
/** Parse `tail` arguments; `error` is a usage error message. */
|
|
55
71
|
export function parseTailArgs(args: string[]): { args: TailArgs } | { error: string } {
|
|
@@ -63,7 +79,11 @@ export function parseTailArgs(args: string[]): { args: TailArgs } | { error: str
|
|
|
63
79
|
samplingRate: undefined,
|
|
64
80
|
search: undefined,
|
|
65
81
|
outcome: [],
|
|
82
|
+
file: undefined,
|
|
83
|
+
lines: 10,
|
|
84
|
+
follow: true,
|
|
66
85
|
};
|
|
86
|
+
let linesGiven = false;
|
|
67
87
|
const split = (value: string) =>
|
|
68
88
|
value
|
|
69
89
|
.split(",")
|
|
@@ -73,6 +93,7 @@ export function parseTailArgs(args: string[]): { args: TailArgs } | { error: str
|
|
|
73
93
|
const arg = args[index];
|
|
74
94
|
if (arg === "--recent") parsed.recent = true;
|
|
75
95
|
else if (arg === "--sprout") parsed.sprout = true;
|
|
96
|
+
else if (arg === "--no-follow") parsed.follow = false;
|
|
76
97
|
else if (arg.startsWith("--")) {
|
|
77
98
|
const eq = arg.indexOf("=");
|
|
78
99
|
const flag = eq < 0 ? arg : arg.slice(0, eq);
|
|
@@ -87,12 +108,28 @@ export function parseTailArgs(args: string[]): { args: TailArgs } | { error: str
|
|
|
87
108
|
else if (flag === "--method") parsed.method.push(...split(value).map((method) => method.toUpperCase()));
|
|
88
109
|
else if (flag === "--outcome") parsed.outcome.push(...split(value));
|
|
89
110
|
else if (flag === "--sampling-rate") parsed.samplingRate = value;
|
|
90
|
-
else parsed.
|
|
111
|
+
else if (flag === "--file") parsed.file = value;
|
|
112
|
+
else if (flag === "--lines") {
|
|
113
|
+
const lines = Number(value);
|
|
114
|
+
if (value.trim() === "" || !Number.isInteger(lines) || lines < 0 || lines > MAX_LINES)
|
|
115
|
+
return { error: `tail: --lines must be a whole number from 0 to ${MAX_LINES} (got "${value}")` };
|
|
116
|
+
parsed.lines = lines;
|
|
117
|
+
linesGiven = true;
|
|
118
|
+
} else parsed.search = value;
|
|
91
119
|
} else if (parsed.dir === undefined) parsed.dir = arg;
|
|
92
120
|
else return { error: `tail: unexpected argument "${arg}"` };
|
|
93
121
|
}
|
|
94
122
|
const filtered =
|
|
95
123
|
parsed.status.length || parsed.method.length || parsed.outcome.length || parsed.samplingRate || parsed.search;
|
|
124
|
+
if (parsed.file === undefined) {
|
|
125
|
+
if (linesGiven) return { error: "tail: --lines needs --file" };
|
|
126
|
+
if (!parsed.follow) return { error: "tail: --no-follow needs --file" };
|
|
127
|
+
} else {
|
|
128
|
+
if (parsed.recent || parsed.sprout) return { error: "tail: --file is an alternative to --recent and --sprout" };
|
|
129
|
+
if (parsed.dir !== undefined) return { error: "tail: --file reads a log, not a project" };
|
|
130
|
+
if (parsed.samplingRate !== undefined || parsed.outcome.length)
|
|
131
|
+
return { error: "tail: --sampling-rate and --outcome are not available with --file" };
|
|
132
|
+
}
|
|
96
133
|
if (parsed.recent && parsed.sprout) return { error: "tail: --recent and --sprout are alternatives" };
|
|
97
134
|
if ((parsed.recent || parsed.sprout) && filtered)
|
|
98
135
|
return { error: "tail: filters apply to the live stream, not --recent or --sprout" };
|