@logboard/cli 1.0.0
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/.env.example +37 -0
- package/README.md +200 -0
- package/bin/logboard +536 -0
- package/client/logger.js +309 -0
- package/config/index.js +142 -0
- package/config.js +2 -0
- package/controllers/AnalyticsController.js +46 -0
- package/controllers/ApiAnalyticsController.js +129 -0
- package/controllers/ApiKeyController.js +58 -0
- package/controllers/AuthController.js +131 -0
- package/controllers/HealthController.js +56 -0
- package/controllers/LogController.js +197 -0
- package/controllers/OrgController.js +152 -0
- package/controllers/RoleConfigController.js +20 -0
- package/controllers/SettingsController.js +39 -0
- package/controllers/StreamController.js +55 -0
- package/controllers/UiController.js +789 -0
- package/controllers/UserController.js +79 -0
- package/lib/batchWriter.js +57 -0
- package/lib/cleanup.js +67 -0
- package/lib/ejs.js +103 -0
- package/lib/emitter.js +5 -0
- package/lib/healthMonitor.js +245 -0
- package/lib/logger.js +21 -0
- package/lib/streams.js +32 -0
- package/lib/theme.js +77 -0
- package/lib/userStore.js +13 -0
- package/lib/utils.js +44 -0
- package/middleware/apiKey.js +82 -0
- package/middleware/auth.js +55 -0
- package/middleware/ipWhitelist.js +59 -0
- package/middleware/org.js +85 -0
- package/middleware/pageAccess.js +20 -0
- package/middleware/rateLimit.js +29 -0
- package/middleware/roles.js +11 -0
- package/package.json +77 -0
- package/routes/alerts.js +18 -0
- package/routes/analytics.js +26 -0
- package/routes/api-analytics.js +30 -0
- package/routes/api-keys.js +12 -0
- package/routes/archive.js +91 -0
- package/routes/audit.js +50 -0
- package/routes/auth.js +22 -0
- package/routes/bookmarks.js +13 -0
- package/routes/health.js +11 -0
- package/routes/logs.js +88 -0
- package/routes/metrics.js +66 -0
- package/routes/notifications.js +14 -0
- package/routes/orgs.js +98 -0
- package/routes/registration.js +202 -0
- package/routes/role-config.js +97 -0
- package/routes/saved-searches.js +12 -0
- package/routes/server.js +151 -0
- package/routes/settings.js +28 -0
- package/routes/status.js +21 -0
- package/routes/stream.js +11 -0
- package/routes/super.js +129 -0
- package/routes/ui.js +120 -0
- package/routes/users.js +13 -0
- package/server.js +172 -0
- package/services/AlertRulesService.js +323 -0
- package/services/AnalyticsService.js +665 -0
- package/services/ApiAnalyticsService.js +471 -0
- package/services/ApiKeyService.js +166 -0
- package/services/AuditService.js +249 -0
- package/services/AuthService.js +234 -0
- package/services/BookmarkService.js +49 -0
- package/services/GlobalSettingsService.js +44 -0
- package/services/LogService.js +1066 -0
- package/services/MetricsService.js +116 -0
- package/services/NotificationService.js +70 -0
- package/services/OrgService.js +217 -0
- package/services/ReportService.js +247 -0
- package/services/RoleConfigService.js +201 -0
- package/services/SavedSearchService.js +63 -0
- package/services/SettingsService.js +220 -0
- package/services/UserService.js +121 -0
- package/setup.js +132 -0
- package/views/404.ejs +8 -0
- package/views/alerts.ejs +190 -0
- package/views/analytics.ejs +209 -0
- package/views/api-analytics.ejs +660 -0
- package/views/api-keys.ejs +150 -0
- package/views/archive.ejs +123 -0
- package/views/audit.ejs +314 -0
- package/views/bookmarks.ejs +54 -0
- package/views/custom-dashboard.ejs +162 -0
- package/views/dashboard.ejs +186 -0
- package/views/diff.ejs +98 -0
- package/views/health.ejs +269 -0
- package/views/heatmap.ejs +126 -0
- package/views/insights.ejs +334 -0
- package/views/invite.ejs +74 -0
- package/views/live.ejs +299 -0
- package/views/login.ejs +64 -0
- package/views/logo.png +0 -0
- package/views/logs.ejs +754 -0
- package/views/notifications.ejs +58 -0
- package/views/partials/head.ejs +282 -0
- package/views/partials/sidebar.ejs +168 -0
- package/views/register.ejs +100 -0
- package/views/roles.ejs +279 -0
- package/views/saved-searches.ejs +51 -0
- package/views/service-map.ejs +142 -0
- package/views/settings.ejs +1159 -0
- package/views/sidebar.ejs +129 -0
- package/views/status.ejs +100 -0
- package/views/super-admin-admins.ejs +58 -0
- package/views/super-admin-analytics.ejs +49 -0
- package/views/super-admin-orgs.ejs +310 -0
- package/views/super-admin-profile.ejs +77 -0
- package/views/super-admin-settings.ejs +108 -0
- package/views/super-admin-system.ejs +46 -0
- package/views/users.ejs +153 -0
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const fsP = require('fs').promises;
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
const config = require('../config');
|
|
7
|
+
const emitter = require('../lib/emitter');
|
|
8
|
+
const logger = require('../lib/logger');
|
|
9
|
+
const {
|
|
10
|
+
getToday,
|
|
11
|
+
sanitizeAppName,
|
|
12
|
+
sanitizeDate,
|
|
13
|
+
safeLogPath,
|
|
14
|
+
ensureDir,
|
|
15
|
+
formatBytes,
|
|
16
|
+
} = require('../lib/utils');
|
|
17
|
+
|
|
18
|
+
class LogService {
|
|
19
|
+
constructor (batchWriter, org) {
|
|
20
|
+
this.batchWriter = batchWriter;
|
|
21
|
+
this._org = org || null;
|
|
22
|
+
this._logsDir = (org && org.logsDir) ? org.logsDir : require('../config').LOG_BASE_DIR;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Ingest ──────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalise and ingest an array of raw log strings.
|
|
29
|
+
* Emits each log on the SSE emitter.
|
|
30
|
+
*/
|
|
31
|
+
async ingest (rawLogs, defaultAppName = 'unknown') {
|
|
32
|
+
const today = getToday();
|
|
33
|
+
let written = 0;
|
|
34
|
+
const parsedBatch = [];
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < rawLogs.length; i++) {
|
|
37
|
+
const raw = rawLogs[i];
|
|
38
|
+
if (typeof raw !== 'string') {
|
|
39
|
+
throw Object.assign(new TypeError(`logs[${i}] must be a string`), {
|
|
40
|
+
status: 400,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Resolve appName: line-level appName wins, but reserved names fall back to defaultAppName > param > 'unknown'
|
|
45
|
+
let appName = defaultAppName;
|
|
46
|
+
let parsed = null;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(raw);
|
|
49
|
+
if (parsed) {
|
|
50
|
+
parsedBatch.push(parsed);
|
|
51
|
+
}
|
|
52
|
+
if (parsed?.appName) {
|
|
53
|
+
appName = parsed.appName;
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
try {
|
|
57
|
+
appName = sanitizeAppName(appName);
|
|
58
|
+
} catch {
|
|
59
|
+
appName = defaultAppName || '';
|
|
60
|
+
}
|
|
61
|
+
// Skip lines that would create reserved-name directories
|
|
62
|
+
const _RESV = [
|
|
63
|
+
'app',
|
|
64
|
+
'unknown',
|
|
65
|
+
'_archive',
|
|
66
|
+
'_tmp',
|
|
67
|
+
'logboard',
|
|
68
|
+
'system',
|
|
69
|
+
];
|
|
70
|
+
if (!appName || _RESV.includes(appName.toLowerCase())) {
|
|
71
|
+
appName = defaultAppName;
|
|
72
|
+
}
|
|
73
|
+
if (!appName || _RESV.includes(appName.toLowerCase())) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Handle nested api_request in data[] (some SDK versions wrap it)
|
|
78
|
+
// Extract and store separately in {appName}-requests/ folder
|
|
79
|
+
if (parsed && Array.isArray(parsed.data)) {
|
|
80
|
+
for (const item of parsed.data) {
|
|
81
|
+
if (item && item.type === 'api_request' && item.method && item.path) {
|
|
82
|
+
const reqAppName = appName.replace(/-requests$/, '') + '-requests';
|
|
83
|
+
const reqLine = JSON.stringify({
|
|
84
|
+
...item,
|
|
85
|
+
ts: item.ts || parsed.ts || new Date().toISOString(),
|
|
86
|
+
level: item.level || (item.statusCode >= 500 ? 'error' : item.statusCode >= 400 ? 'warn' : 'info'),
|
|
87
|
+
appName: reqAppName,
|
|
88
|
+
});
|
|
89
|
+
this.batchWriter.write(reqAppName, today, reqLine, this._logsDir);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Auto-extract system_metrics into MetricsService
|
|
95
|
+
// Fix appName: old SDK sends 'app', override with resolved service name
|
|
96
|
+
if (parsed && parsed.type === 'system_metrics') {
|
|
97
|
+
if (!parsed.appName || parsed.appName === 'app') parsed.appName = appName;
|
|
98
|
+
try {
|
|
99
|
+
const MetricsService = require('./MetricsService');
|
|
100
|
+
new MetricsService(this._org).ingest(appName, {
|
|
101
|
+
ts: parsed.ts, cpuPct: parsed.cpu?.usedPct,
|
|
102
|
+
ramPct: parsed.ram?.usedPct, ramUsedMB: parsed.ram?.usedMB,
|
|
103
|
+
ramTotalMB: parsed.ram?.totalMB, diskPct: parsed.disk?.usedPct,
|
|
104
|
+
heapUsedMB: parsed.process?.heapUsedMB, heapTotalMB: parsed.process?.heapTotalMB,
|
|
105
|
+
heapPct: parsed.process?.heapUsedMB && parsed.process?.heapTotalMB
|
|
106
|
+
? Math.round(parsed.process.heapUsedMB / parsed.process.heapTotalMB * 100) : null,
|
|
107
|
+
rssMB: parsed.process?.rssMB, uptimeSec: parsed.process?.uptime,
|
|
108
|
+
nodeVersion: parsed.process?.version, hostname: parsed.hostname,
|
|
109
|
+
}).catch(() => {});
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
// Ensure structured JSON format on disk
|
|
113
|
+
const structured = parsed
|
|
114
|
+
? JSON.stringify({
|
|
115
|
+
ts: parsed.ts || new Date().toISOString(),
|
|
116
|
+
level: (parsed.level || 'info').toLowerCase(),
|
|
117
|
+
appName,
|
|
118
|
+
...parsed,
|
|
119
|
+
})
|
|
120
|
+
: JSON.stringify({
|
|
121
|
+
ts: new Date().toISOString(),
|
|
122
|
+
level: 'info',
|
|
123
|
+
appName,
|
|
124
|
+
message: raw,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this.batchWriter.write(appName, today, structured, this._logsDir);
|
|
128
|
+
|
|
129
|
+
// Alert on error level
|
|
130
|
+
const level = parsed?.level?.toLowerCase();
|
|
131
|
+
if (level === 'error' && config.WEBHOOK_URL) {
|
|
132
|
+
this._fireWebhook(structured).catch((err) =>
|
|
133
|
+
logger.warn(`[Alert] Webhook failed: ${err.message}`),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (config.ENABLE_STREAM) {
|
|
138
|
+
emitter.emit('log', structured);
|
|
139
|
+
}
|
|
140
|
+
written++;
|
|
141
|
+
}
|
|
142
|
+
// Evaluate alert rules on the batch
|
|
143
|
+
if (parsedBatch.length) {
|
|
144
|
+
try {
|
|
145
|
+
const rules = require('./AlertRulesService');
|
|
146
|
+
const SettingsService = require('./SettingsService');
|
|
147
|
+
const settings = await new SettingsService(this._org).get();
|
|
148
|
+
rules.evaluate(parsedBatch, settings).catch(() => {});
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
return written;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async _fireWebhook (logStr) {
|
|
155
|
+
const axios = require('axios');
|
|
156
|
+
await axios.post(
|
|
157
|
+
config.WEBHOOK_URL,
|
|
158
|
+
{ alert: 'error_log', log: JSON.parse(logStr) },
|
|
159
|
+
{ timeout: 5000 },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── List ─────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
async getServices () {
|
|
166
|
+
let dirs = [];
|
|
167
|
+
try {
|
|
168
|
+
dirs = await fsP.readdir(this._logsDir);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (e.code !== 'ENOENT') {
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const results = [];
|
|
175
|
+
for (const app of dirs) {
|
|
176
|
+
const appPath = path.join(this._logsDir, app);
|
|
177
|
+
try {
|
|
178
|
+
const stat = await fsP.stat(appPath);
|
|
179
|
+
if (!stat.isDirectory()) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const files = await fsP.readdir(appPath);
|
|
183
|
+
const dates = files
|
|
184
|
+
.filter((f) => /^\d{4}-\d{2}-\d{2}\.log$/.test(f))
|
|
185
|
+
.map((f) => f.slice(0, 10))
|
|
186
|
+
.sort()
|
|
187
|
+
.reverse();
|
|
188
|
+
let bytes = 0;
|
|
189
|
+
for (const f of files.filter((f) => f.endsWith('.log'))) {
|
|
190
|
+
try {
|
|
191
|
+
bytes += (await fsP.stat(path.join(appPath, f))).size;
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
194
|
+
// Skip reserved/internal appNames that should never appear in UI
|
|
195
|
+
if (
|
|
196
|
+
['app', 'unknown', '_archive', '_tmp', 'logboard', 'system'].includes(
|
|
197
|
+
app.toLowerCase(),
|
|
198
|
+
)
|
|
199
|
+
) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// Skip reserved/internal names
|
|
203
|
+
const _reserved = [
|
|
204
|
+
'app',
|
|
205
|
+
'unknown',
|
|
206
|
+
'_archive',
|
|
207
|
+
'_tmp',
|
|
208
|
+
'logboard',
|
|
209
|
+
'system',
|
|
210
|
+
];
|
|
211
|
+
if (_reserved.includes(app.toLowerCase())) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
results.push({
|
|
215
|
+
appName: app,
|
|
216
|
+
dates,
|
|
217
|
+
bytes,
|
|
218
|
+
bytesHuman: formatBytes(bytes),
|
|
219
|
+
});
|
|
220
|
+
} catch {}
|
|
221
|
+
}
|
|
222
|
+
return results;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Tail ─────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
async tail (appName, date, lines = 100) {
|
|
228
|
+
appName = sanitizeAppName(appName);
|
|
229
|
+
date = sanitizeDate(date);
|
|
230
|
+
const filePath = safeLogPath(this._logsDir, appName, date);
|
|
231
|
+
if (!fs.existsSync(filePath)) {
|
|
232
|
+
return { appName, date, lines: [] };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
const stream = fs.createReadStream(filePath, 'utf8');
|
|
237
|
+
const rl = readline.createInterface({
|
|
238
|
+
input: stream,
|
|
239
|
+
crlfDelay: Infinity,
|
|
240
|
+
});
|
|
241
|
+
const buffer = [];
|
|
242
|
+
rl.on('line', (l) => {
|
|
243
|
+
if (l.trim()) {
|
|
244
|
+
buffer.push(l);
|
|
245
|
+
if (buffer.length > lines) {
|
|
246
|
+
buffer.shift();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
rl.on('close', () => resolve({ appName, date, lines: buffer }));
|
|
251
|
+
rl.on('error', reject);
|
|
252
|
+
stream.on('error', reject);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Search ───────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Search log files.
|
|
260
|
+
* @param {object} opts - { service, level, q, date, limit, offset }
|
|
261
|
+
*/
|
|
262
|
+
async search ({ service, level, q, date, limit = 500, offset = 0 }) {
|
|
263
|
+
limit = Math.min(parseInt(limit, 10) || 500, 5000);
|
|
264
|
+
|
|
265
|
+
offset = Math.max(parseInt(offset, 10) || 0, 0);
|
|
266
|
+
|
|
267
|
+
// Determine which files to search
|
|
268
|
+
const filesToSearch = await this._resolveFiles(service, date);
|
|
269
|
+
const matches = [];
|
|
270
|
+
let scanned = 0;
|
|
271
|
+
let skipped = 0;
|
|
272
|
+
|
|
273
|
+
for (const { appName: svc, filePath } of filesToSearch) {
|
|
274
|
+
if (!fs.existsSync(filePath)) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await new Promise((resolve, reject) => {
|
|
279
|
+
const stream = fs.createReadStream(filePath, 'utf8');
|
|
280
|
+
const rl = readline.createInterface({
|
|
281
|
+
input: stream,
|
|
282
|
+
crlfDelay: Infinity,
|
|
283
|
+
});
|
|
284
|
+
rl.on('line', (line) => {
|
|
285
|
+
if (!line.trim()) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
scanned++;
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(line);
|
|
291
|
+
if (level && parsed.level !== level) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (q && !line.toLowerCase().includes(q.toLowerCase())) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (skipped < offset) {
|
|
298
|
+
skipped++;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (matches.length < limit) {
|
|
302
|
+
matches.push({ ...parsed, _appName: svc });
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
if (q && !line.toLowerCase().includes(q.toLowerCase())) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (skipped < offset) {
|
|
309
|
+
skipped++;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (matches.length < limit) {
|
|
313
|
+
matches.push({ _raw: line, _appName: svc });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
rl.on('close', resolve);
|
|
318
|
+
rl.on('error', reject);
|
|
319
|
+
stream.on('error', reject);
|
|
320
|
+
});
|
|
321
|
+
if (matches.length >= limit) {
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return { matches, total: matches.length, scanned, offset, limit };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── Replay ───────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
/** Return logs written in the last `minutes` minutes across all services */
|
|
331
|
+
async replay (minutes = 30) {
|
|
332
|
+
minutes = Math.min(parseInt(minutes, 10) || 30, 1440);
|
|
333
|
+
const cutoff = new Date(Date.now() - minutes * 60_000);
|
|
334
|
+
const today = getToday();
|
|
335
|
+
const results = [];
|
|
336
|
+
|
|
337
|
+
const services = await this.getServices();
|
|
338
|
+
for (const { appName } of services) {
|
|
339
|
+
const filePath = safeLogPath(this._logsDir, appName, today);
|
|
340
|
+
if (!fs.existsSync(filePath)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await new Promise((resolve) => {
|
|
345
|
+
const stream = fs.createReadStream(filePath, 'utf8');
|
|
346
|
+
const rl = readline.createInterface({
|
|
347
|
+
input: stream,
|
|
348
|
+
crlfDelay: Infinity,
|
|
349
|
+
});
|
|
350
|
+
rl.on('line', (line) => {
|
|
351
|
+
if (!line.trim()) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const parsed = JSON.parse(line);
|
|
356
|
+
if (new Date(parsed.ts) >= cutoff) {
|
|
357
|
+
results.push({ ...parsed, _appName: appName });
|
|
358
|
+
}
|
|
359
|
+
} catch {}
|
|
360
|
+
});
|
|
361
|
+
rl.on('close', resolve);
|
|
362
|
+
rl.on('error', resolve);
|
|
363
|
+
stream.on('error', resolve);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
results.sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
|
367
|
+
return results;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ─── Download (streamed, filtered) ───────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
async download (appName, date, filters, res) {
|
|
373
|
+
appName = sanitizeAppName(appName);
|
|
374
|
+
|
|
375
|
+
date = sanitizeDate(date);
|
|
376
|
+
const filePath = safeLogPath(this._logsDir, appName, date);
|
|
377
|
+
if (!fs.existsSync(filePath)) {
|
|
378
|
+
throw Object.assign(new Error('Log file not found'), { status: 404 });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const { level, q } = filters;
|
|
382
|
+
res.setHeader(
|
|
383
|
+
'Content-Disposition',
|
|
384
|
+
`attachment; filename="${appName}-${date}.log"`,
|
|
385
|
+
);
|
|
386
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
387
|
+
|
|
388
|
+
if (!level && !q) {
|
|
389
|
+
fs.createReadStream(filePath).pipe(res);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const stream = fs.createReadStream(filePath, 'utf8');
|
|
394
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
395
|
+
rl.on('line', (line) => {
|
|
396
|
+
if (!line.trim()) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const p = JSON.parse(line);
|
|
401
|
+
if (level && p.level !== level) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (q && !line.toLowerCase().includes(q.toLowerCase())) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
if (q && !line.toLowerCase().includes(q.toLowerCase())) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
res.write(`${line}\n`);
|
|
413
|
+
});
|
|
414
|
+
rl.on('close', () => res.end());
|
|
415
|
+
rl.on('error', (err) => {
|
|
416
|
+
logger.error(`[Download] ${err.message}`);
|
|
417
|
+
res.end();
|
|
418
|
+
});
|
|
419
|
+
stream.on('error', (err) => {
|
|
420
|
+
logger.error(`[Download] ${err.message}`);
|
|
421
|
+
res.end();
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
async _resolveFiles (service, date) {
|
|
428
|
+
const files = [];
|
|
429
|
+
if (service && date) {
|
|
430
|
+
try {
|
|
431
|
+
sanitizeAppName(service);
|
|
432
|
+
sanitizeDate(date);
|
|
433
|
+
} catch {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
files.push({
|
|
437
|
+
appName: service,
|
|
438
|
+
filePath: safeLogPath(this._logsDir, service, date),
|
|
439
|
+
});
|
|
440
|
+
} else if (service) {
|
|
441
|
+
try {
|
|
442
|
+
sanitizeAppName(service);
|
|
443
|
+
} catch {
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
const appDir = path.join(this._logsDir, service);
|
|
447
|
+
let allFiles = [];
|
|
448
|
+
try {
|
|
449
|
+
allFiles = await fsP.readdir(appDir);
|
|
450
|
+
} catch {}
|
|
451
|
+
for (const f of allFiles
|
|
452
|
+
|
|
453
|
+
.filter((f) => /^\d{4}-\d{2}-\d{2}\.log$/.test(f))
|
|
454
|
+
.sort()
|
|
455
|
+
.reverse()) {
|
|
456
|
+
files.push({ appName: service, filePath: path.join(appDir, f) });
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
const services = await this.getServices();
|
|
460
|
+
const targetDate = date || getToday();
|
|
461
|
+
for (const { appName } of services) {
|
|
462
|
+
try {
|
|
463
|
+
sanitizeDate(targetDate);
|
|
464
|
+
files.push({
|
|
465
|
+
appName,
|
|
466
|
+
filePath: safeLogPath(this._logsDir, appName, targetDate),
|
|
467
|
+
});
|
|
468
|
+
} catch {}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return files;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── Error clustering across a date range ─────────────────────────────────
|
|
475
|
+
async clusterErrorsRange (appName, fromDate, toDate) {
|
|
476
|
+
appName = sanitizeAppName(appName);
|
|
477
|
+
sanitizeDate(fromDate);
|
|
478
|
+
sanitizeDate(toDate);
|
|
479
|
+
const clusters = {};
|
|
480
|
+
const appDir = path.join(this._logsDir, appName);
|
|
481
|
+
let files = [];
|
|
482
|
+
try {
|
|
483
|
+
files = (await fsP.readdir(appDir)).filter((f) =>
|
|
484
|
+
/^\d{4}-\d{2}-\d{2}\.log$/.test(f),
|
|
485
|
+
);
|
|
486
|
+
} catch {}
|
|
487
|
+
const inRange = files
|
|
488
|
+
.filter((f) => {
|
|
489
|
+
const d = f.slice(0, 10);
|
|
490
|
+
return d >= fromDate && d <= toDate;
|
|
491
|
+
})
|
|
492
|
+
.sort();
|
|
493
|
+
|
|
494
|
+
for (const fname of inRange) {
|
|
495
|
+
const fp = path.join(appDir, fname);
|
|
496
|
+
|
|
497
|
+
await new Promise((resolve) => {
|
|
498
|
+
const rl = readline.createInterface({
|
|
499
|
+
input: fs.createReadStream(fp, 'utf8'),
|
|
500
|
+
crlfDelay: Infinity,
|
|
501
|
+
});
|
|
502
|
+
rl.on('line', (line) => {
|
|
503
|
+
if (!line.trim()) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
const p = JSON.parse(line);
|
|
508
|
+
if ((p.level || '').toLowerCase() !== 'error') {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const norm = (p.message || line)
|
|
512
|
+
.replace(/[0-9a-f]{8}-[0-9a-f-]{27}/gi, '<uuid>')
|
|
513
|
+
.replace(/\d+/g, 'N')
|
|
514
|
+
.replace(/["'][^"']{20,}["']/g, '"<str>"')
|
|
515
|
+
.slice(0, 100);
|
|
516
|
+
if (!clusters[norm]) {
|
|
517
|
+
clusters[norm] = {
|
|
518
|
+
pattern: norm,
|
|
519
|
+
count: 0,
|
|
520
|
+
first: p.ts,
|
|
521
|
+
last: p.ts,
|
|
522
|
+
examples: [],
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
clusters[norm].count++;
|
|
526
|
+
if (p.ts > clusters[norm].last) {
|
|
527
|
+
clusters[norm].last = p.ts;
|
|
528
|
+
}
|
|
529
|
+
if (p.ts < clusters[norm].first) {
|
|
530
|
+
clusters[norm].first = p.ts;
|
|
531
|
+
}
|
|
532
|
+
if (clusters[norm].examples.length < 3) {
|
|
533
|
+
clusters[norm].examples.push(p.message || line);
|
|
534
|
+
}
|
|
535
|
+
} catch {}
|
|
536
|
+
});
|
|
537
|
+
rl.on('close', resolve);
|
|
538
|
+
rl.on('error', resolve);
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
return Object.values(clusters).sort((a, b) => b.count - a.count);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ─── NEW: time-range search (local tz offset from client) ────────────────
|
|
545
|
+
async searchTimeRange (
|
|
546
|
+
appName,
|
|
547
|
+
date,
|
|
548
|
+
{ fromTime, toTime, level, q, tzOffset = 0, limit = 500 },
|
|
549
|
+
) {
|
|
550
|
+
const filePath = safeLogPath(this._logsDir, appName, date);
|
|
551
|
+
if (!fs.existsSync(filePath)) {
|
|
552
|
+
return { lines: [], total: 0 };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// tzOffset is in minutes (client's getTimezoneOffset() — note: sign is INVERTED in JS)
|
|
556
|
+
// To convert UTC log ts to local: local = UTC - tzOffset minutes
|
|
557
|
+
const fromMs = fromTime
|
|
558
|
+
? new Date(`${date}T${fromTime}:00Z`).getTime() + tzOffset * 60_000
|
|
559
|
+
: null;
|
|
560
|
+
const toMs = toTime
|
|
561
|
+
? new Date(`${date}T${toTime}:59Z`).getTime() + tzOffset * 60_000
|
|
562
|
+
: null;
|
|
563
|
+
|
|
564
|
+
const lines = [];
|
|
565
|
+
await new Promise((resolve) => {
|
|
566
|
+
const rl = readline.createInterface({
|
|
567
|
+
input: fs.createReadStream(filePath, 'utf8'),
|
|
568
|
+
crlfDelay: Infinity,
|
|
569
|
+
});
|
|
570
|
+
rl.on('line', (line) => {
|
|
571
|
+
if (!line.trim() || lines.length >= limit) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
const p = JSON.parse(line);
|
|
576
|
+
const ts = new Date(p.ts).getTime();
|
|
577
|
+
if (fromMs && ts < fromMs) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (toMs && ts > toMs) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (level && (p.level || '').toLowerCase() !== level) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (q && !line.toLowerCase().includes(q.toLowerCase())) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
lines.push(line);
|
|
590
|
+
} catch {
|
|
591
|
+
if (q && !line.toLowerCase().includes(q.toLowerCase())) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
lines.push(line);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
rl.on('close', resolve);
|
|
598
|
+
rl.on('error', resolve);
|
|
599
|
+
});
|
|
600
|
+
return { lines, total: lines.length };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ─── NEW: context lines (N before + N after a line index) ────────────────
|
|
604
|
+
async getContext (appName, date, lineIndex, contextN = 10) {
|
|
605
|
+
const filePath = safeLogPath(this._logsDir, appName, date);
|
|
606
|
+
if (!fs.existsSync(filePath)) {
|
|
607
|
+
return [];
|
|
608
|
+
}
|
|
609
|
+
const allLines = [];
|
|
610
|
+
await new Promise((resolve) => {
|
|
611
|
+
const rl = readline.createInterface({
|
|
612
|
+
input: fs.createReadStream(filePath, 'utf8'),
|
|
613
|
+
crlfDelay: Infinity,
|
|
614
|
+
});
|
|
615
|
+
rl.on('line', (l) => {
|
|
616
|
+
if (l.trim()) {
|
|
617
|
+
allLines.push(l);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
rl.on('close', resolve);
|
|
621
|
+
rl.on('error', resolve);
|
|
622
|
+
});
|
|
623
|
+
const start = Math.max(0, lineIndex - contextN);
|
|
624
|
+
const end = Math.min(allLines.length - 1, lineIndex + contextN);
|
|
625
|
+
return allLines.slice(start, end + 1).map((line, i) => ({
|
|
626
|
+
line,
|
|
627
|
+
index: start + i,
|
|
628
|
+
isFocus: start + i === lineIndex,
|
|
629
|
+
}));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ─── NEW: download as .txt (human-readable, one line per log) ────────────
|
|
633
|
+
async downloadTxt (appName, date, filters, res) {
|
|
634
|
+
appName = sanitizeAppName(appName);
|
|
635
|
+
date = sanitizeDate(date);
|
|
636
|
+
const filePath = safeLogPath(this._logsDir, appName, date);
|
|
637
|
+
if (!fs.existsSync(filePath)) {
|
|
638
|
+
throw Object.assign(new Error('Log file not found'), { status: 404 });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const { level, q, fromTime, toTime, tzOffset = 0 } = filters;
|
|
642
|
+
const fromMs = fromTime
|
|
643
|
+
? new Date(`${date}T${fromTime}:00Z`).getTime()
|
|
644
|
+
+ Number(tzOffset) * 60_000
|
|
645
|
+
: null;
|
|
646
|
+
const toMs = toTime
|
|
647
|
+
? new Date(`${date}T${toTime}:59Z`).getTime() + Number(tzOffset) * 60_000
|
|
648
|
+
: null;
|
|
649
|
+
|
|
650
|
+
res.setHeader(
|
|
651
|
+
'Content-Disposition',
|
|
652
|
+
`attachment; filename="${appName}-${date}.txt"`,
|
|
653
|
+
);
|
|
654
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
655
|
+
|
|
656
|
+
const stream = fs.createReadStream(filePath, 'utf8');
|
|
657
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
658
|
+
rl.on('line', (line) => {
|
|
659
|
+
if (!line.trim()) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
const p = JSON.parse(line);
|
|
664
|
+
const ts = new Date(p.ts).getTime();
|
|
665
|
+
if (fromMs && ts < fromMs) {
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (toMs && ts > toMs) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (level && (p.level || '').toLowerCase() !== level) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (q && !line.toLowerCase().includes(q.toLowerCase())) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const localTs = p.ts ? new Date(p.ts).toLocaleString() : '';
|
|
678
|
+
const lvl = (p.level || 'INFO').toUpperCase().padEnd(5);
|
|
679
|
+
const msg = p.message || line;
|
|
680
|
+
res.write(`[${localTs}] [${lvl}] [${p.appName || appName}] ${msg}\n`);
|
|
681
|
+
} catch {
|
|
682
|
+
if (q && !line.toLowerCase().includes(q.toLowerCase())) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
res.write(`${line}\n`);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
rl.on('close', () => res.end());
|
|
689
|
+
rl.on('error', (err) => res.end());
|
|
690
|
+
stream.on('error', (err) => res.end());
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── Multi-date search ────────────────────────────────────────────────────
|
|
694
|
+
async searchMultiDate (
|
|
695
|
+
appName,
|
|
696
|
+
fromDate,
|
|
697
|
+
toDate,
|
|
698
|
+
{ level, q, limit = 500 } = {},
|
|
699
|
+
) {
|
|
700
|
+
appName = sanitizeAppName(appName);
|
|
701
|
+
sanitizeDate(fromDate);
|
|
702
|
+
sanitizeDate(toDate);
|
|
703
|
+
const lines = [],
|
|
704
|
+
seen = new Set();
|
|
705
|
+
const appDir = path.join(this._logsDir, appName);
|
|
706
|
+
let allFiles = [];
|
|
707
|
+
try {
|
|
708
|
+
allFiles = await fsP.readdir(appDir);
|
|
709
|
+
} catch {}
|
|
710
|
+
const dateFiles = allFiles
|
|
711
|
+
.filter((f) => /^\d{4}-\d{2}-\d{2}\.log$/.test(f))
|
|
712
|
+
.filter((f) => {
|
|
713
|
+
const d = f.slice(0, 10);
|
|
714
|
+
return d >= fromDate && d <= toDate;
|
|
715
|
+
})
|
|
716
|
+
.sort();
|
|
717
|
+
for (const fname of dateFiles) {
|
|
718
|
+
if (lines.length >= limit) {
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
const fp = path.join(appDir, fname);
|
|
722
|
+
await new Promise((resolve) => {
|
|
723
|
+
const rl = readline.createInterface({
|
|
724
|
+
input: fs.createReadStream(fp, 'utf8'),
|
|
725
|
+
crlfDelay: Infinity,
|
|
726
|
+
});
|
|
727
|
+
rl.on('line', (line) => {
|
|
728
|
+
if (!line.trim() || lines.length >= limit) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
const p = JSON.parse(line);
|
|
733
|
+
if (level && (p.level || '').toLowerCase() !== level) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (q && !line.toLowerCase().includes(q.toLowerCase())) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
} catch {
|
|
740
|
+
if (q && !line.toLowerCase().includes(q.toLowerCase())) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
lines.push(line);
|
|
745
|
+
});
|
|
746
|
+
rl.on('close', resolve);
|
|
747
|
+
rl.on('error', resolve);
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
return { lines, total: lines.length, fromDate, toDate };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ── Log diff ─────────────────────────────────────────────────────────────
|
|
754
|
+
async getDiff (appName, dateA, dateB) {
|
|
755
|
+
appName = sanitizeAppName(appName);
|
|
756
|
+
sanitizeDate(dateA);
|
|
757
|
+
sanitizeDate(dateB);
|
|
758
|
+
const countPatterns = async (date) => {
|
|
759
|
+
const fp = safeLogPath(this._logsDir, appName, date);
|
|
760
|
+
const patterns = {};
|
|
761
|
+
if (!fs.existsSync(fp)) {
|
|
762
|
+
return { patterns, total: 0, errors: 0 };
|
|
763
|
+
}
|
|
764
|
+
let total = 0,
|
|
765
|
+
errors = 0;
|
|
766
|
+
await new Promise((resolve) => {
|
|
767
|
+
const rl = readline.createInterface({
|
|
768
|
+
input: fs.createReadStream(fp, 'utf8'),
|
|
769
|
+
crlfDelay: Infinity,
|
|
770
|
+
});
|
|
771
|
+
rl.on('line', (line) => {
|
|
772
|
+
if (!line.trim()) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
total++;
|
|
776
|
+
try {
|
|
777
|
+
const p = JSON.parse(line);
|
|
778
|
+
const lvl = (p.level || 'info').toLowerCase();
|
|
779
|
+
if (lvl === 'error' || lvl === 'fatal') {
|
|
780
|
+
errors++;
|
|
781
|
+
// Normalise message: strip numbers/uuids for grouping
|
|
782
|
+
const msg = (p.message || '')
|
|
783
|
+
.replace(/[0-9a-f]{8}-[0-9a-f-]{27}/gi, '<uuid>')
|
|
784
|
+
.replace(/\d+/g, 'N')
|
|
785
|
+
.slice(0, 120);
|
|
786
|
+
patterns[msg] = (patterns[msg] || 0) + 1;
|
|
787
|
+
}
|
|
788
|
+
} catch {}
|
|
789
|
+
});
|
|
790
|
+
rl.on('close', resolve);
|
|
791
|
+
rl.on('error', resolve);
|
|
792
|
+
});
|
|
793
|
+
return { patterns, total, errors };
|
|
794
|
+
};
|
|
795
|
+
const [a, b] = await Promise.all([
|
|
796
|
+
countPatterns(dateA),
|
|
797
|
+
countPatterns(dateB),
|
|
798
|
+
]);
|
|
799
|
+
const allKeys = new Set([
|
|
800
|
+
...Object.keys(a.patterns),
|
|
801
|
+
...Object.keys(b.patterns),
|
|
802
|
+
]);
|
|
803
|
+
const rows = [];
|
|
804
|
+
for (const msg of allKeys) {
|
|
805
|
+
const ca = a.patterns[msg] || 0,
|
|
806
|
+
cb = b.patterns[msg] || 0;
|
|
807
|
+
rows.push({
|
|
808
|
+
msg,
|
|
809
|
+
countA: ca,
|
|
810
|
+
countB: cb,
|
|
811
|
+
delta: cb - ca,
|
|
812
|
+
pct: ca === 0 ? null : Math.round(((cb - ca) / ca) * 100),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
|
|
816
|
+
return {
|
|
817
|
+
dateA,
|
|
818
|
+
dateB,
|
|
819
|
+
totalA: a.total,
|
|
820
|
+
totalB: b.total,
|
|
821
|
+
errorsA: a.errors,
|
|
822
|
+
errorsB: b.errors,
|
|
823
|
+
rows: rows.slice(0, 50),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ── Service dependency map — requestId + name cross-referencing ───────────
|
|
828
|
+
async getServiceMap (date) {
|
|
829
|
+
sanitizeDate(date);
|
|
830
|
+
const services = await this.getServices();
|
|
831
|
+
const nodes = new Set();
|
|
832
|
+
const traceMap = {}; // traceId → [appName, ...]
|
|
833
|
+
const edges = {}; // "A→B" → count
|
|
834
|
+
|
|
835
|
+
// Pass 1: collect all requestIds per service
|
|
836
|
+
for (const { appName } of services) {
|
|
837
|
+
const fp = safeLogPath(this._logsDir, appName, date);
|
|
838
|
+
if (!fs.existsSync(fp)) {
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
nodes.add(appName);
|
|
842
|
+
await new Promise((resolve) => {
|
|
843
|
+
const rl = readline.createInterface({
|
|
844
|
+
input: fs.createReadStream(fp, 'utf8'),
|
|
845
|
+
crlfDelay: Infinity,
|
|
846
|
+
});
|
|
847
|
+
rl.on('line', (line) => {
|
|
848
|
+
if (!line.trim()) {
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
852
|
+
const p = JSON.parse(line);
|
|
853
|
+
// Use requestId, traceId, or correlationId for cross-service linking
|
|
854
|
+
const tid
|
|
855
|
+
= p.requestId || p.traceId || p.correlationId || p['x-request-id'];
|
|
856
|
+
if (tid && typeof tid === 'string' && tid.length > 4) {
|
|
857
|
+
if (!traceMap[tid]) {
|
|
858
|
+
traceMap[tid] = new Set();
|
|
859
|
+
}
|
|
860
|
+
traceMap[tid].add(appName);
|
|
861
|
+
}
|
|
862
|
+
// Fallback: name-in-log heuristic
|
|
863
|
+
const str = JSON.stringify(p);
|
|
864
|
+
for (const { appName: other } of services) {
|
|
865
|
+
if (other !== appName && str.includes(other)) {
|
|
866
|
+
const key = `${appName}→${other}`;
|
|
867
|
+
edges[key] = (edges[key] || 0) + 0.5; // lower weight than requestId
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} catch {}
|
|
871
|
+
});
|
|
872
|
+
rl.on('close', resolve);
|
|
873
|
+
rl.on('error', resolve);
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Pass 2: build edges from shared requestIds
|
|
878
|
+
for (const [, svcs] of Object.entries(traceMap)) {
|
|
879
|
+
const arr = [...svcs];
|
|
880
|
+
for (let i = 0; i < arr.length; i++) {
|
|
881
|
+
for (let j = i + 1; j < arr.length; j++) {
|
|
882
|
+
const key = `${arr[i]}→${arr[j]}`;
|
|
883
|
+
edges[key] = (edges[key] || 0) + 1;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
nodes: [...nodes].map((n) => ({ id: n, label: n })),
|
|
890
|
+
edges: Object.entries(edges)
|
|
891
|
+
.filter(([, w]) => w >= 1)
|
|
892
|
+
.map(([k, w]) => {
|
|
893
|
+
const [s, t] = k.split('→');
|
|
894
|
+
return { source: s, target: t, weight: Math.round(w) };
|
|
895
|
+
})
|
|
896
|
+
.sort((a, b) => b.weight - a.weight),
|
|
897
|
+
traceCount: Object.keys(traceMap).length,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ── Error clustering ─────────────────────────────────────────────────────
|
|
902
|
+
async clusterErrors (appName, date) {
|
|
903
|
+
appName = sanitizeAppName(appName);
|
|
904
|
+
sanitizeDate(date);
|
|
905
|
+
const fp = safeLogPath(this._logsDir, appName, date);
|
|
906
|
+
if (!fs.existsSync(fp)) {
|
|
907
|
+
return [];
|
|
908
|
+
}
|
|
909
|
+
const clusters = {};
|
|
910
|
+
await new Promise((resolve) => {
|
|
911
|
+
const rl = readline.createInterface({
|
|
912
|
+
input: fs.createReadStream(fp, 'utf8'),
|
|
913
|
+
crlfDelay: Infinity,
|
|
914
|
+
});
|
|
915
|
+
rl.on('line', (line) => {
|
|
916
|
+
if (!line.trim()) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
const p = JSON.parse(line);
|
|
921
|
+
if ((p.level || '').toLowerCase() !== 'error') {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const norm = (p.message || line)
|
|
925
|
+
.replace(/[0-9a-f]{8}-[0-9a-f-]{27}/gi, '<uuid>')
|
|
926
|
+
.replace(/\d+/g, 'N')
|
|
927
|
+
.replace(/["'][^"']{20,}["']/g, '"<str>"')
|
|
928
|
+
.slice(0, 100);
|
|
929
|
+
if (!clusters[norm]) {
|
|
930
|
+
clusters[norm] = {
|
|
931
|
+
pattern: norm,
|
|
932
|
+
count: 0,
|
|
933
|
+
first: p.ts,
|
|
934
|
+
last: p.ts,
|
|
935
|
+
examples: [],
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
clusters[norm].count++;
|
|
939
|
+
clusters[norm].last = p.ts;
|
|
940
|
+
if (clusters[norm].examples.length < 3) {
|
|
941
|
+
clusters[norm].examples.push(p.message || line);
|
|
942
|
+
}
|
|
943
|
+
} catch {}
|
|
944
|
+
});
|
|
945
|
+
rl.on('close', resolve);
|
|
946
|
+
rl.on('error', resolve);
|
|
947
|
+
});
|
|
948
|
+
return Object.values(clusters).sort((a, b) => b.count - a.count);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ── Anomaly detection ────────────────────────────────────────────────────
|
|
952
|
+
async detectAnomalies (appName) {
|
|
953
|
+
appName = sanitizeAppName(appName);
|
|
954
|
+
const today = getToday();
|
|
955
|
+
const appDir = path.join(this._logsDir, appName);
|
|
956
|
+
let files = [];
|
|
957
|
+
try {
|
|
958
|
+
files = (await fsP.readdir(appDir))
|
|
959
|
+
.filter((f) => /^\d{4}-\d{2}-\d{2}\.log$/.test(f))
|
|
960
|
+
.sort()
|
|
961
|
+
.slice(-30);
|
|
962
|
+
} catch {}
|
|
963
|
+
const dailyErrors = [];
|
|
964
|
+
for (const fname of files) {
|
|
965
|
+
const fp = path.join(appDir, fname);
|
|
966
|
+
const counts = await this._countLevels(fp).catch(() => ({
|
|
967
|
+
total: 0,
|
|
968
|
+
error: 0,
|
|
969
|
+
}));
|
|
970
|
+
dailyErrors.push({
|
|
971
|
+
date: fname.slice(0, 10),
|
|
972
|
+
errors: counts.error || 0,
|
|
973
|
+
total: counts.total || 0,
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
if (dailyErrors.length < 7) {
|
|
977
|
+
return { anomalies: [], baseline: 0, dailyErrors };
|
|
978
|
+
}
|
|
979
|
+
// Compute baseline mean + stddev (excluding last 1 day)
|
|
980
|
+
const baseline = dailyErrors.slice(0, -1);
|
|
981
|
+
const mean = baseline.reduce((a, b) => a + b.errors, 0) / baseline.length;
|
|
982
|
+
const stddev = Math.sqrt(
|
|
983
|
+
baseline.reduce((a, b) => a + Math.pow(b.errors - mean, 2), 0)
|
|
984
|
+
/ baseline.length,
|
|
985
|
+
);
|
|
986
|
+
const threshold = mean + 2 * stddev;
|
|
987
|
+
const anomalies = dailyErrors.filter(
|
|
988
|
+
(d) => d.errors > threshold && d.errors > mean * 1.5,
|
|
989
|
+
);
|
|
990
|
+
return {
|
|
991
|
+
anomalies,
|
|
992
|
+
baseline: Math.round(mean),
|
|
993
|
+
stddev: Math.round(stddev),
|
|
994
|
+
threshold: Math.round(threshold),
|
|
995
|
+
dailyErrors,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ── Trace ID linking ────────────────────────────────────────────────────
|
|
1000
|
+
async findByTraceId (traceId, date) {
|
|
1001
|
+
if (!traceId) {
|
|
1002
|
+
throw Object.assign(new Error('traceId required'), { status: 400 });
|
|
1003
|
+
}
|
|
1004
|
+
date = date || getToday();
|
|
1005
|
+
sanitizeDate(date);
|
|
1006
|
+
const services = await this.getServices();
|
|
1007
|
+
const results = [];
|
|
1008
|
+
const clean = traceId.replace(/[^a-zA-Z0-9_\-]/g, '').slice(0, 128);
|
|
1009
|
+
for (const { appName } of services) {
|
|
1010
|
+
const fp = safeLogPath(this._logsDir, appName, date);
|
|
1011
|
+
if (!fs.existsSync(fp)) {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
await new Promise((resolve) => {
|
|
1015
|
+
const rl = readline.createInterface({
|
|
1016
|
+
input: fs.createReadStream(fp, 'utf8'),
|
|
1017
|
+
crlfDelay: Infinity,
|
|
1018
|
+
});
|
|
1019
|
+
rl.on('line', (line) => {
|
|
1020
|
+
if (line.includes(clean)) {
|
|
1021
|
+
try {
|
|
1022
|
+
results.push({ ...JSON.parse(line), _appName: appName });
|
|
1023
|
+
} catch {
|
|
1024
|
+
results.push({ raw: line, _appName: appName });
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
rl.on('close', resolve);
|
|
1029
|
+
rl.on('error', resolve);
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
results.sort((a, b) => new Date(a.ts || 0) - new Date(b.ts || 0));
|
|
1033
|
+
return results;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async _countLevels (filePath) {
|
|
1037
|
+
const counts = { total: 0, error: 0, warn: 0, info: 0, debug: 0 };
|
|
1038
|
+
if (!fs.existsSync(filePath)) {
|
|
1039
|
+
return counts;
|
|
1040
|
+
}
|
|
1041
|
+
await new Promise((resolve) => {
|
|
1042
|
+
const rl = readline.createInterface({
|
|
1043
|
+
input: fs.createReadStream(filePath, 'utf8'),
|
|
1044
|
+
crlfDelay: Infinity,
|
|
1045
|
+
});
|
|
1046
|
+
rl.on('line', (line) => {
|
|
1047
|
+
if (!line.trim()) {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
counts.total++;
|
|
1051
|
+
try {
|
|
1052
|
+
const p = JSON.parse(line);
|
|
1053
|
+
const l = (p.level || 'info').toLowerCase();
|
|
1054
|
+
if (counts[l] !== undefined) {
|
|
1055
|
+
counts[l]++;
|
|
1056
|
+
}
|
|
1057
|
+
} catch {}
|
|
1058
|
+
});
|
|
1059
|
+
rl.on('close', resolve);
|
|
1060
|
+
rl.on('error', resolve);
|
|
1061
|
+
});
|
|
1062
|
+
return counts;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
module.exports = LogService;
|