-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathlogs.ts
More file actions
306 lines (270 loc) · 13 KB
/
logs.ts
File metadata and controls
306 lines (270 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
import { Flags, Command } from "@oclif/core";
import defaults from "../../common-utils/defaults.js";
import fs from "fs";
import fsPromise from "fs/promises";
import path from "path";
interface LogEntry {
timestamp: Date | null;
stream: "stdout" | "stderr" | null;
lines: string[];
}
export default class Logs extends Command {
static override description =
"View the latest BitSocial daemon log file. By default dumps the full log and exits. Use --follow to stream new output in real-time (like tail -f).";
static override flags = {
follow: Flags.boolean({
char: "f",
description: "Follow log output in real-time (like tail -f)",
default: false
}),
tail: Flags.string({
char: "n",
description: 'Number of log entries to show from the end. Use "all" to show everything.',
default: "all"
}),
since: Flags.string({
description:
"Show logs since timestamp (ISO 8601, e.g. 2026-01-02T13:23:37Z) or relative time (e.g. 30s, 42m, 2h, 1d)",
required: false
}),
until: Flags.string({
description:
"Show logs before timestamp (ISO 8601, e.g. 2026-01-02T13:23:37Z) or relative time (e.g. 30s, 42m, 2h, 1d)",
required: false
}),
logPath: Flags.directory({
description: "Specify the directory containing log files",
required: false
}),
stdout: Flags.boolean({
description: "Show only stdout log entries",
default: false,
exclusive: ["stderr"]
}),
stderr: Flags.boolean({
description: "Show only stderr log entries (output of pkc-logger library)",
default: false,
exclusive: ["stdout"]
})
};
static override examples = [
"bitsocial logs",
"bitsocial logs -f",
"bitsocial logs -n 50",
"bitsocial logs --since 5m",
"bitsocial logs --since 2026-01-02T13:23:37Z --until 2026-01-02T14:00:00Z",
"bitsocial logs --since 1h -f",
"bitsocial logs --stdout",
"bitsocial logs --stderr",
"bitsocial logs --stdout -f"
];
private async _findLatestLogFile(logPath: string): Promise<string> {
let entries: fs.Dirent[];
try {
entries = await fsPromise.readdir(logPath, { withFileTypes: true });
} catch {
this.error(`Log directory does not exist: ${logPath}\nHave you started the daemon yet?`);
}
const logFiles = entries
.filter((entry) => entry.isFile() && entry.name.startsWith("bitsocial_cli_daemon_") && entry.name.endsWith(".log"))
.map((entry) => entry.name)
.sort();
if (logFiles.length === 0) {
this.error(`No log files found in ${logPath}\nHave you started the daemon yet?`);
}
return path.join(logPath, logFiles[logFiles.length - 1]);
}
_parseTimestamp(value: string): Date {
// Try relative duration first: 30s, 42m, 2h, 1d
const relativeMatch = value.match(/^(\d+)([smhd])$/);
if (relativeMatch) {
const amount = parseInt(relativeMatch[1], 10);
const unit = relativeMatch[2];
const multipliers: Record<string, number> = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
return new Date(Date.now() - amount * multipliers[unit]);
}
// Try ISO timestamp
const date = new Date(value);
if (isNaN(date.getTime())) {
this.error(
`Invalid timestamp: "${value}". Use ISO 8601 format (e.g. 2026-01-02T13:23:37Z) or relative time (e.g. 30s, 42m, 2h, 1d)`
);
}
return date;
}
_extractTimestamp(line: string): Date | null {
const match = line.match(/^\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\] /);
if (!match) return null;
return new Date(match[1]);
}
_extractStream(line: string): "stdout" | "stderr" | null {
const match = line.match(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[(stdout|stderr)\] /);
if (!match) return null;
return match[1] as "stdout" | "stderr";
}
_parseLogEntries(content: string): LogEntry[] {
const lines = content.split("\n");
const entries: LogEntry[] = [];
for (const line of lines) {
const timestamp = this._extractTimestamp(line);
if (timestamp !== null) {
// New timestamped entry
const stream = this._extractStream(line);
entries.push({ timestamp, stream, lines: [line] });
} else if (entries.length > 0) {
// Continuation line — belongs to the previous entry
entries[entries.length - 1].lines.push(line);
} else {
// Line before any timestamped entry (legacy/header)
entries.push({ timestamp: null, stream: null, lines: [line] });
}
}
return entries;
}
_filterEntries(entries: LogEntry[], since?: Date, until?: Date): LogEntry[] {
return entries.filter((entry) => {
if (entry.timestamp === null) {
// Legacy entries with no timestamp: exclude if --since is set, include otherwise
return !since;
}
if (since && entry.timestamp < since) return false;
if (until && entry.timestamp > until) return false;
return true;
});
}
_filterByStream(entries: LogEntry[], stream: "stdout" | "stderr"): LogEntry[] {
return entries.filter((entry) => entry.stream === stream);
}
_tailEntries(entries: LogEntry[], tailValue: string): LogEntry[] {
if (tailValue === "all") return entries;
const n = parseInt(tailValue, 10);
if (isNaN(n) || n < 0) {
this.error(`Invalid --tail value: "${tailValue}". Must be a non-negative integer or "all".`);
}
if (n === 0) return [];
return entries.slice(-n);
}
async run() {
const { flags } = await this.parse(Logs);
const logPath = flags.logPath ?? defaults.PKC_LOG_PATH;
const latestLogFile = await this._findLatestLogFile(logPath);
const since = flags.since ? this._parseTimestamp(flags.since) : undefined;
const until = flags.until ? this._parseTimestamp(flags.until) : undefined;
const streamFilter = flags.stdout ? "stdout" as const : flags.stderr ? "stderr" as const : undefined;
if (!flags.follow) {
const content = await fsPromise.readFile(latestLogFile, "utf-8");
const entries = this._parseLogEntries(content);
const filtered = this._filterEntries(entries, since, until);
const streamFiltered = streamFilter ? this._filterByStream(filtered, streamFilter) : filtered;
const tailed = this._tailEntries(streamFiltered, flags.tail);
const output = tailed.map((e) => e.lines.join("\n")).join("\n");
if (output) process.stdout.write(output + "\n");
return;
}
// Follow mode: dump existing content (filtered + tailed) then watch for new data
let currentLogFile = latestLogFile;
const existingContent = await fsPromise.readFile(currentLogFile, "utf-8");
const entries = this._parseLogEntries(existingContent);
const filtered = this._filterEntries(entries, since, until);
const streamFiltered = streamFilter ? this._filterByStream(filtered, streamFilter) : filtered;
const tailed = this._tailEntries(streamFiltered, flags.tail);
const initialOutput = tailed.map((e) => e.lines.join("\n")).join("\n");
if (initialOutput) process.stdout.write(initialOutput + "\n");
const stat = await fsPromise.stat(currentLogFile);
let position = stat.size;
let pendingBuffer = "";
// Watch for new data using polling (works across filesystems including Docker volumes)
const readNewData = async () => {
try {
const currentStat = await fsPromise.stat(currentLogFile);
if (currentStat.size > position) {
const fd = await fsPromise.open(currentLogFile, "r");
const buf = new Uint8Array(currentStat.size - position);
const { bytesRead } = await fd.read(buf, 0, buf.length, position);
await fd.close();
position += bytesRead;
const chunk = pendingBuffer + new TextDecoder().decode(buf.subarray(0, bytesRead));
// Split into complete lines; keep any incomplete trailing line in the buffer
const lastNewline = chunk.lastIndexOf("\n");
if (lastNewline === -1) {
pendingBuffer = chunk;
return;
}
pendingBuffer = chunk.slice(lastNewline + 1);
const completeText = chunk.slice(0, lastNewline + 1);
if (!since && !until && !streamFilter) {
// No filtering — pass through directly
process.stdout.write(completeText);
} else {
const newEntries = this._parseLogEntries(completeText.replace(/\n$/, ""));
const filteredNew = this._filterEntries(newEntries, since, until);
const streamFilteredNew = streamFilter ? this._filterByStream(filteredNew, streamFilter) : filteredNew;
const output = streamFilteredNew.map((e) => e.lines.join("\n")).join("\n");
if (output) process.stdout.write(output + "\n");
}
}
} catch {
// File may have been rotated or deleted
}
};
// Periodically check if a newer log file has appeared (e.g. after daemon restart)
const checkForNewLogFile = async () => {
try {
const newestFile = await this._findLatestLogFile(logPath);
if (newestFile === currentLogFile) return;
// Flush any remaining partial line from old file
if (pendingBuffer) {
if (!since && !until && !streamFilter) {
process.stdout.write(pendingBuffer + "\n");
} else {
const pbEntries = this._parseLogEntries(pendingBuffer);
const pbFiltered = this._filterEntries(pbEntries, since, until);
const pbStreamFiltered = streamFilter ? this._filterByStream(pbFiltered, streamFilter) : pbFiltered;
const pbOutput = pbStreamFiltered.map((e) => e.lines.join("\n")).join("\n");
if (pbOutput) process.stdout.write(pbOutput + "\n");
}
}
// Switch watchers
fs.unwatchFile(currentLogFile, readNewData);
currentLogFile = newestFile;
pendingBuffer = "";
process.stderr.write(`\n--- switched to new log file: ${path.basename(newestFile)} ---\n\n`);
// Read and output entire new file content (with filters, no tail limit)
const newContent = await fsPromise.readFile(currentLogFile, "utf-8");
if (newContent) {
if (!since && !until && !streamFilter) {
process.stdout.write(newContent);
} else {
const newEntries = this._parseLogEntries(newContent.replace(/\n$/, ""));
const filteredNew = this._filterEntries(newEntries, since, until);
const streamFilteredNew = streamFilter
? this._filterByStream(filteredNew, streamFilter)
: filteredNew;
const output = streamFilteredNew.map((e) => e.lines.join("\n")).join("\n");
if (output) process.stdout.write(output + "\n");
}
}
const newStat = await fsPromise.stat(currentLogFile);
position = newStat.size;
fs.watchFile(currentLogFile, { interval: 300 }, readNewData);
} catch {
// Directory listing failed or file disappeared — retry next cycle
}
};
fs.watchFile(currentLogFile, { interval: 300 }, readNewData);
const newFileCheckInterval = setInterval(checkForNewLogFile, 3000);
// Keep the process alive and clean up on exit
process.on("SIGINT", () => {
clearInterval(newFileCheckInterval);
fs.unwatchFile(currentLogFile, readNewData);
process.exit(0);
});
process.on("SIGTERM", () => {
clearInterval(newFileCheckInterval);
fs.unwatchFile(currentLogFile, readNewData);
process.exit(0);
});
// Keep process alive
await new Promise(() => {});
}
}