@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.
Files changed (114) hide show
  1. package/.env.example +37 -0
  2. package/README.md +200 -0
  3. package/bin/logboard +536 -0
  4. package/client/logger.js +309 -0
  5. package/config/index.js +142 -0
  6. package/config.js +2 -0
  7. package/controllers/AnalyticsController.js +46 -0
  8. package/controllers/ApiAnalyticsController.js +129 -0
  9. package/controllers/ApiKeyController.js +58 -0
  10. package/controllers/AuthController.js +131 -0
  11. package/controllers/HealthController.js +56 -0
  12. package/controllers/LogController.js +197 -0
  13. package/controllers/OrgController.js +152 -0
  14. package/controllers/RoleConfigController.js +20 -0
  15. package/controllers/SettingsController.js +39 -0
  16. package/controllers/StreamController.js +55 -0
  17. package/controllers/UiController.js +789 -0
  18. package/controllers/UserController.js +79 -0
  19. package/lib/batchWriter.js +57 -0
  20. package/lib/cleanup.js +67 -0
  21. package/lib/ejs.js +103 -0
  22. package/lib/emitter.js +5 -0
  23. package/lib/healthMonitor.js +245 -0
  24. package/lib/logger.js +21 -0
  25. package/lib/streams.js +32 -0
  26. package/lib/theme.js +77 -0
  27. package/lib/userStore.js +13 -0
  28. package/lib/utils.js +44 -0
  29. package/middleware/apiKey.js +82 -0
  30. package/middleware/auth.js +55 -0
  31. package/middleware/ipWhitelist.js +59 -0
  32. package/middleware/org.js +85 -0
  33. package/middleware/pageAccess.js +20 -0
  34. package/middleware/rateLimit.js +29 -0
  35. package/middleware/roles.js +11 -0
  36. package/package.json +77 -0
  37. package/routes/alerts.js +18 -0
  38. package/routes/analytics.js +26 -0
  39. package/routes/api-analytics.js +30 -0
  40. package/routes/api-keys.js +12 -0
  41. package/routes/archive.js +91 -0
  42. package/routes/audit.js +50 -0
  43. package/routes/auth.js +22 -0
  44. package/routes/bookmarks.js +13 -0
  45. package/routes/health.js +11 -0
  46. package/routes/logs.js +88 -0
  47. package/routes/metrics.js +66 -0
  48. package/routes/notifications.js +14 -0
  49. package/routes/orgs.js +98 -0
  50. package/routes/registration.js +202 -0
  51. package/routes/role-config.js +97 -0
  52. package/routes/saved-searches.js +12 -0
  53. package/routes/server.js +151 -0
  54. package/routes/settings.js +28 -0
  55. package/routes/status.js +21 -0
  56. package/routes/stream.js +11 -0
  57. package/routes/super.js +129 -0
  58. package/routes/ui.js +120 -0
  59. package/routes/users.js +13 -0
  60. package/server.js +172 -0
  61. package/services/AlertRulesService.js +323 -0
  62. package/services/AnalyticsService.js +665 -0
  63. package/services/ApiAnalyticsService.js +471 -0
  64. package/services/ApiKeyService.js +166 -0
  65. package/services/AuditService.js +249 -0
  66. package/services/AuthService.js +234 -0
  67. package/services/BookmarkService.js +49 -0
  68. package/services/GlobalSettingsService.js +44 -0
  69. package/services/LogService.js +1066 -0
  70. package/services/MetricsService.js +116 -0
  71. package/services/NotificationService.js +70 -0
  72. package/services/OrgService.js +217 -0
  73. package/services/ReportService.js +247 -0
  74. package/services/RoleConfigService.js +201 -0
  75. package/services/SavedSearchService.js +63 -0
  76. package/services/SettingsService.js +220 -0
  77. package/services/UserService.js +121 -0
  78. package/setup.js +132 -0
  79. package/views/404.ejs +8 -0
  80. package/views/alerts.ejs +190 -0
  81. package/views/analytics.ejs +209 -0
  82. package/views/api-analytics.ejs +660 -0
  83. package/views/api-keys.ejs +150 -0
  84. package/views/archive.ejs +123 -0
  85. package/views/audit.ejs +314 -0
  86. package/views/bookmarks.ejs +54 -0
  87. package/views/custom-dashboard.ejs +162 -0
  88. package/views/dashboard.ejs +186 -0
  89. package/views/diff.ejs +98 -0
  90. package/views/health.ejs +269 -0
  91. package/views/heatmap.ejs +126 -0
  92. package/views/insights.ejs +334 -0
  93. package/views/invite.ejs +74 -0
  94. package/views/live.ejs +299 -0
  95. package/views/login.ejs +64 -0
  96. package/views/logo.png +0 -0
  97. package/views/logs.ejs +754 -0
  98. package/views/notifications.ejs +58 -0
  99. package/views/partials/head.ejs +282 -0
  100. package/views/partials/sidebar.ejs +168 -0
  101. package/views/register.ejs +100 -0
  102. package/views/roles.ejs +279 -0
  103. package/views/saved-searches.ejs +51 -0
  104. package/views/service-map.ejs +142 -0
  105. package/views/settings.ejs +1159 -0
  106. package/views/sidebar.ejs +129 -0
  107. package/views/status.ejs +100 -0
  108. package/views/super-admin-admins.ejs +58 -0
  109. package/views/super-admin-analytics.ejs +49 -0
  110. package/views/super-admin-orgs.ejs +310 -0
  111. package/views/super-admin-profile.ejs +77 -0
  112. package/views/super-admin-settings.ejs +108 -0
  113. package/views/super-admin-system.ejs +46 -0
  114. 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;