@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,471 @@
|
|
|
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 { getToday, sanitizeAppName, sanitizeDate, formatBytes } = require('../lib/utils');
|
|
8
|
+
|
|
9
|
+
class ApiAnalyticsService {
|
|
10
|
+
constructor (org) {
|
|
11
|
+
this._org = org || null;
|
|
12
|
+
// Use org logsDir if available, fallback to flat LOG_BASE_DIR
|
|
13
|
+
this._logsDir = (org && org.logsDir) ? org.logsDir : require('../config').LOG_BASE_DIR;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── List services that have request logs ─────────────────────────────────
|
|
17
|
+
|
|
18
|
+
async getApiServices () {
|
|
19
|
+
let dirs = [];
|
|
20
|
+
try { dirs = await fsP.readdir(this._logsDir); } catch (e) { if (e.code !== 'ENOENT') { throw e; } }
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const dir of dirs) {
|
|
24
|
+
console.log('Checking log directory for API requests:', dir); // --- IGNORE ---
|
|
25
|
+
try {
|
|
26
|
+
const fullPath = path.join(this._logsDir, dir);
|
|
27
|
+
const stat = await fsP.stat(fullPath);
|
|
28
|
+
if (!stat.isDirectory()) { continue; }
|
|
29
|
+
|
|
30
|
+
if (dir.endsWith('-requests')) {
|
|
31
|
+
// Dedicated requests folder — primary source (Format A from new SDK)
|
|
32
|
+
const baseApp = dir.slice(0, -('-requests'.length));
|
|
33
|
+
if (!seen.has(baseApp)) {
|
|
34
|
+
seen.add(baseApp);
|
|
35
|
+
results.push({ appName: dir, baseApp, dir: fullPath });
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
// Regular log folder — check if it contains api_request entries (Format B)
|
|
39
|
+
// Only include if a -requests folder doesn't already exist for this service
|
|
40
|
+
const reqDir = path.join(this._logsDir, `${dir }-requests`);
|
|
41
|
+
let hasReqDir = false;
|
|
42
|
+
try { hasReqDir = (await fsP.stat(reqDir)).isDirectory(); } catch {}
|
|
43
|
+
if (!hasReqDir && !seen.has(dir)) {
|
|
44
|
+
// Peek at today's log to see if it contains api_request entries
|
|
45
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
46
|
+
const todayFile = path.join(fullPath, `${today}.log`);
|
|
47
|
+
let hasApiReqs = false;
|
|
48
|
+
try {
|
|
49
|
+
const sample = await fsP.readFile(todayFile, 'utf8');
|
|
50
|
+
hasApiReqs = sample.includes('"api_request"');
|
|
51
|
+
} catch {}
|
|
52
|
+
if (hasApiReqs) {
|
|
53
|
+
seen.add(dir);
|
|
54
|
+
results.push({ appName: dir, baseApp: dir, dir: fullPath });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Parse all api_request entries for a service+date ───────────────────
|
|
64
|
+
|
|
65
|
+
async _parseRequestLogs (appName, date) {
|
|
66
|
+
const safeApp = sanitizeAppName(appName);
|
|
67
|
+
const safeDate = sanitizeDate(date);
|
|
68
|
+
const filePath = path.join(this._logsDir, safeApp, `${safeDate}.log`);
|
|
69
|
+
const entries = [];
|
|
70
|
+
|
|
71
|
+
if (!fs.existsSync(filePath)) { return entries; }
|
|
72
|
+
|
|
73
|
+
await new Promise((resolve) => {
|
|
74
|
+
const stream = fs.createReadStream(filePath, 'utf8');
|
|
75
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
76
|
+
rl.on('line', (line) => {
|
|
77
|
+
if (!line.trim()) { return; }
|
|
78
|
+
try {
|
|
79
|
+
const p = JSON.parse(line);
|
|
80
|
+
if (p.type === 'api_request' && p.method && p.path) { entries.push(p); }
|
|
81
|
+
} catch {}
|
|
82
|
+
});
|
|
83
|
+
rl.on('close', resolve);
|
|
84
|
+
rl.on('error', resolve);
|
|
85
|
+
stream.on('error', resolve);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return entries;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Compute per-endpoint stats ──────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
_computeEndpointStats (entries) {
|
|
94
|
+
const map = new Map();
|
|
95
|
+
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
const key = `${e.method} ${e.path}`;
|
|
98
|
+
if (!map.has(key)) {
|
|
99
|
+
map.set(key, {
|
|
100
|
+
method: e.method,
|
|
101
|
+
path: e.path,
|
|
102
|
+
count: 0,
|
|
103
|
+
durations: [],
|
|
104
|
+
totalReqBytes: 0,
|
|
105
|
+
totalResBytes: 0,
|
|
106
|
+
errors: 0, // 4xx + 5xx
|
|
107
|
+
errors5xx: 0,
|
|
108
|
+
statusCodes: {},
|
|
109
|
+
lastSeen: null,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const s = map.get(key);
|
|
113
|
+
s.count++;
|
|
114
|
+
s.durations.push(e.durationMs || 0);
|
|
115
|
+
s.totalReqBytes += e.reqSizeBytes || 0;
|
|
116
|
+
s.totalResBytes += e.resSizeBytes || 0;
|
|
117
|
+
if (e.statusCode >= 400) { s.errors++; }
|
|
118
|
+
if (e.statusCode >= 500) { s.errors5xx++; }
|
|
119
|
+
s.statusCodes[e.statusCode] = (s.statusCodes[e.statusCode] || 0) + 1;
|
|
120
|
+
if (!s.lastSeen || e.ts > s.lastSeen) { s.lastSeen = e.ts; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const results = [];
|
|
124
|
+
for (const s of map.values()) {
|
|
125
|
+
const sorted = [...s.durations].sort((a, b) => a - b);
|
|
126
|
+
const total = sorted.reduce((a, b) => a + b, 0);
|
|
127
|
+
const avg = s.count ? total / s.count : 0;
|
|
128
|
+
const p95idx = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
|
|
129
|
+
const p99idx = Math.max(0, Math.ceil(sorted.length * 0.99) - 1);
|
|
130
|
+
|
|
131
|
+
results.push({
|
|
132
|
+
method: s.method,
|
|
133
|
+
path: s.path,
|
|
134
|
+
count: s.count,
|
|
135
|
+
avgDuration: Math.round(avg * 10) / 10,
|
|
136
|
+
p95Duration: sorted[p95idx] || 0,
|
|
137
|
+
p99Duration: sorted[p99idx] || 0,
|
|
138
|
+
maxDuration: sorted[sorted.length - 1] || 0,
|
|
139
|
+
minDuration: sorted[0] || 0,
|
|
140
|
+
avgReqBytes: s.count ? Math.round(s.totalReqBytes / s.count) : 0,
|
|
141
|
+
avgResBytes: s.count ? Math.round(s.totalResBytes / s.count) : 0,
|
|
142
|
+
totalReqBytes: s.totalReqBytes,
|
|
143
|
+
totalResBytes: s.totalResBytes,
|
|
144
|
+
errors: s.errors,
|
|
145
|
+
errors5xx: s.errors5xx,
|
|
146
|
+
errorRate: s.count ? ((s.errors / s.count) * 100).toFixed(1) : '0.0',
|
|
147
|
+
statusCodes: s.statusCodes,
|
|
148
|
+
lastSeen: s.lastSeen,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return results;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Overview stats card ─────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
async getOverview (appName, date) {
|
|
157
|
+
date = date || getToday();
|
|
158
|
+
const entries = await this._parseRequestLogs(appName, date);
|
|
159
|
+
if (!entries.length) { return this._emptyOverview(date); }
|
|
160
|
+
|
|
161
|
+
const totalRequests = entries.length;
|
|
162
|
+
const totalErrors = entries.filter((e) => e.statusCode >= 400).length;
|
|
163
|
+
const totalErrors5xx = entries.filter((e) => e.statusCode >= 500).length;
|
|
164
|
+
const avgDuration = entries.reduce((a, e) => a + (e.durationMs || 0), 0) / totalRequests;
|
|
165
|
+
const maxDuration = Math.max(...entries.map((e) => e.durationMs || 0));
|
|
166
|
+
const totalReqBytes = entries.reduce((a, e) => a + (e.reqSizeBytes || 0), 0);
|
|
167
|
+
const totalResBytes = entries.reduce((a, e) => a + (e.resSizeBytes || 0), 0);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
date,
|
|
171
|
+
appName,
|
|
172
|
+
totalRequests,
|
|
173
|
+
totalErrors,
|
|
174
|
+
totalErrors5xx,
|
|
175
|
+
errorRate: totalRequests ? ((totalErrors / totalRequests) * 100).toFixed(1) : '0.0',
|
|
176
|
+
errorRate5xx: totalRequests ? ((totalErrors5xx / totalRequests) * 100).toFixed(1) : '0.0',
|
|
177
|
+
avgDuration: Math.round(avgDuration * 10) / 10,
|
|
178
|
+
maxDuration,
|
|
179
|
+
totalReqBytes,
|
|
180
|
+
totalResBytes,
|
|
181
|
+
totalDataHuman: formatBytes(totalReqBytes + totalResBytes),
|
|
182
|
+
totalResHuman: formatBytes(totalResBytes),
|
|
183
|
+
totalReqHuman: formatBytes(totalReqBytes),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_emptyOverview (date) {
|
|
188
|
+
return {
|
|
189
|
+
date, totalRequests: 0, totalErrors: 0, totalErrors5xx: 0,
|
|
190
|
+
errorRate: '0.0', errorRate5xx: '0.0', avgDuration: 0, maxDuration: 0,
|
|
191
|
+
totalReqBytes: 0, totalResBytes: 0, totalDataHuman: '0 B',
|
|
192
|
+
totalResHuman: '0 B', totalReqHuman: '0 B',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── Hourly breakdown ────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
async getHourlyVolume (appName, date) {
|
|
199
|
+
date = date || getToday();
|
|
200
|
+
const entries = await this._parseRequestLogs(appName, date);
|
|
201
|
+
const hours = Array.from({ length: 24 }, (_, i) => ({
|
|
202
|
+
hour: i, total: 0, ok: 0, warn: 0, error: 0, avgDuration: 0, _durations: [],
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
for (const e of entries) {
|
|
206
|
+
const h = new Date(e.ts).getUTCHours();
|
|
207
|
+
hours[h].total++;
|
|
208
|
+
hours[h]._durations.push(e.durationMs || 0);
|
|
209
|
+
if (e.statusCode >= 500) { hours[h].error++; } else if (e.statusCode >= 400) { hours[h].warn++; } else { hours[h].ok++; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const h of hours) {
|
|
213
|
+
if (h._durations.length) {
|
|
214
|
+
h.avgDuration = Math.round(h._durations.reduce((a, b) => a + b, 0) / h._durations.length);
|
|
215
|
+
}
|
|
216
|
+
delete h._durations;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { date, appName, hours };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── All endpoint stats ───────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
async getEndpointStats (appName, date) {
|
|
225
|
+
date = date || getToday();
|
|
226
|
+
const entries = await this._parseRequestLogs(appName, date);
|
|
227
|
+
return this._computeEndpointStats(entries);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Top slowest endpoints ────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
async getTopSlowest (appName, date, topN = 10) {
|
|
233
|
+
const stats = await this.getEndpointStats(appName, date);
|
|
234
|
+
return stats
|
|
235
|
+
.filter((s) => s.count >= 1)
|
|
236
|
+
.sort((a, b) => b.avgDuration - a.avgDuration)
|
|
237
|
+
.slice(0, topN);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Most errored endpoints ───────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
async getTopErrors (appName, date, topN = 10) {
|
|
243
|
+
const stats = await this.getEndpointStats(appName, date);
|
|
244
|
+
return stats
|
|
245
|
+
.filter((s) => s.errors > 0)
|
|
246
|
+
.sort((a, b) => b.errors - a.errors)
|
|
247
|
+
.slice(0, topN);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── Highest volume endpoints ─────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
async getTopVolume (appName, date, topN = 10) {
|
|
253
|
+
const stats = await this.getEndpointStats(appName, date);
|
|
254
|
+
return stats
|
|
255
|
+
.sort((a, b) => b.count - a.count)
|
|
256
|
+
.slice(0, topN);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Status code distribution ─────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
async getStatusDistribution (appName, date) {
|
|
262
|
+
date = date || getToday();
|
|
263
|
+
const entries = await this._parseRequestLogs(appName, date);
|
|
264
|
+
const dist = {};
|
|
265
|
+
for (const e of entries) {
|
|
266
|
+
const bucket = Math.floor(e.statusCode / 100) * 100;
|
|
267
|
+
dist[bucket] = (dist[bucket] || 0) + 1;
|
|
268
|
+
}
|
|
269
|
+
return { date, appName, distribution: dist, total: entries.length };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Apdex score ──────────────────────────────────────────────────────────
|
|
273
|
+
// Satisfied < 200ms, Tolerating < 800ms, Frustrated ≥ 800ms
|
|
274
|
+
async getApdex (appName, date, tMs = 200) {
|
|
275
|
+
date = date || getToday();
|
|
276
|
+
const entries = await this._parseRequestLogs(appName, date);
|
|
277
|
+
if (!entries.length) { return { score: null, satisfied: 0, tolerating: 0, frustrated: 0, total: 0, tMs }; }
|
|
278
|
+
let satisfied = 0, tolerating = 0, frustrated = 0;
|
|
279
|
+
for (const e of entries) {
|
|
280
|
+
const d = e.durationMs || 0;
|
|
281
|
+
if (d <= tMs) { satisfied++; } else if (d <= tMs * 4) { tolerating++; } else { frustrated++; }
|
|
282
|
+
}
|
|
283
|
+
const total = entries.length;
|
|
284
|
+
const score = Math.round(((satisfied + tolerating / 2) / total) * 100) / 100;
|
|
285
|
+
return { score, satisfied, tolerating, frustrated, total, tMs };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── 7-day duration trend ─────────────────────────────────────────────────
|
|
289
|
+
async getDailyTrend (appName, days = 7) {
|
|
290
|
+
const trend = [];
|
|
291
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
292
|
+
const d = new Date(); d.setDate(d.getDate() - i);
|
|
293
|
+
const date = d.toISOString().slice(0, 10);
|
|
294
|
+
const ov = await this.getOverview(appName, date).catch(() => null);
|
|
295
|
+
trend.push({
|
|
296
|
+
date,
|
|
297
|
+
total: ov?.totalRequests || 0,
|
|
298
|
+
avgDuration: ov?.avgDuration || 0,
|
|
299
|
+
errorRate: parseFloat(ov?.errorRate || '0'),
|
|
300
|
+
maxDuration: ov?.maxDuration || 0,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return trend;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Top heaviest responses ───────────────────────────────────────────────
|
|
307
|
+
async getTopHeaviest (appName, date, topN = 10) {
|
|
308
|
+
const stats = await this.getEndpointStats(appName, date);
|
|
309
|
+
return stats
|
|
310
|
+
.filter((s) => s.avgResBytes > 0)
|
|
311
|
+
.sort((a, b) => b.avgResBytes - a.avgResBytes)
|
|
312
|
+
.slice(0, topN);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── Individual slowest requests (not averages — actual worst calls) ──────
|
|
316
|
+
async getIndividualSlowest (appName, date, topN = 20) {
|
|
317
|
+
date = date || getToday();
|
|
318
|
+
const entries = await this._parseRequestLogs(appName, date);
|
|
319
|
+
return entries
|
|
320
|
+
.sort((a, b) => (b.durationMs || 0) - (a.durationMs || 0))
|
|
321
|
+
.slice(0, topN)
|
|
322
|
+
.map((e) => ({
|
|
323
|
+
ts: e.ts,
|
|
324
|
+
method: e.method,
|
|
325
|
+
path: e.path,
|
|
326
|
+
statusCode: e.statusCode,
|
|
327
|
+
durationMs: e.durationMs,
|
|
328
|
+
reqSizeBytes: e.reqSizeBytes,
|
|
329
|
+
resSizeBytes: e.resSizeBytes,
|
|
330
|
+
requestId: e.requestId,
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── RPS: peak requests-per-minute from hourly data ──────────────────────
|
|
335
|
+
async getPeakRpm (appName, date) {
|
|
336
|
+
const { hours } = await this.getHourlyVolume(appName, date);
|
|
337
|
+
const peak = hours.reduce((max, h) => h.total > max.total ? h : max, { hour: 0, total: 0 });
|
|
338
|
+
return {
|
|
339
|
+
peakHour: peak.hour,
|
|
340
|
+
peakTotal: peak.total,
|
|
341
|
+
peakRpm: Math.round(peak.total / 60 * 10) / 10,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ─── Date range helper ────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
_dateRange (startDate, endDate) {
|
|
348
|
+
const dates = [];
|
|
349
|
+
try {
|
|
350
|
+
const cur = new Date(`${startDate }T00:00:00Z`);
|
|
351
|
+
const end = new Date(`${endDate }T00:00:00Z`);
|
|
352
|
+
let i = 0;
|
|
353
|
+
while (cur <= end && i < 90) {
|
|
354
|
+
dates.push(cur.toISOString().slice(0, 10));
|
|
355
|
+
cur.setUTCDate(cur.getUTCDate() + 1);
|
|
356
|
+
i++;
|
|
357
|
+
}
|
|
358
|
+
} catch {}
|
|
359
|
+
return dates;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ─── Slow-trend: avg/P95 per day over a date range ───────────────────────
|
|
363
|
+
|
|
364
|
+
async getSlowTrend (appName, startDate, endDate) {
|
|
365
|
+
const dates = this._dateRange(startDate, endDate);
|
|
366
|
+
const trend = [];
|
|
367
|
+
|
|
368
|
+
for (const date of dates) {
|
|
369
|
+
try { sanitizeDate(date); } catch { continue; }
|
|
370
|
+
const entries = await this._parseRequestLogs(appName, date);
|
|
371
|
+
|
|
372
|
+
if (!entries.length) {
|
|
373
|
+
trend.push({ date, avgDuration: 0, p95Duration: 0, totalRequests: 0, slowestPath: '', slowestAvg: 0 });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const sorted = entries.map((e) => e.durationMs || 0).sort((a, b) => a - b);
|
|
378
|
+
const avg = sorted.reduce((a, b) => a + b, 0) / sorted.length;
|
|
379
|
+
const p95idx = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
|
|
380
|
+
|
|
381
|
+
// Slowest endpoint this day
|
|
382
|
+
const epMap = {};
|
|
383
|
+
for (const e of entries) {
|
|
384
|
+
if (!epMap[e.path]) { epMap[e.path] = []; }
|
|
385
|
+
epMap[e.path].push(e.durationMs || 0);
|
|
386
|
+
}
|
|
387
|
+
let slowestPath = '', slowestAvg = 0;
|
|
388
|
+
for (const [path, durs] of Object.entries(epMap)) {
|
|
389
|
+
const epAvg = durs.reduce((a, b) => a + b, 0) / durs.length;
|
|
390
|
+
if (epAvg > slowestAvg) { slowestAvg = epAvg; slowestPath = path; }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
trend.push({
|
|
394
|
+
date,
|
|
395
|
+
avgDuration: Math.round(avg * 10) / 10,
|
|
396
|
+
p95Duration: sorted[p95idx] || 0,
|
|
397
|
+
totalRequests: entries.length,
|
|
398
|
+
slowestPath,
|
|
399
|
+
slowestAvg: Math.round(slowestAvg),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return trend;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ─── Error-trend: error rate per day over a date range ───────────────────
|
|
406
|
+
|
|
407
|
+
async getErrorTrend (appName, startDate, endDate) {
|
|
408
|
+
const dates = this._dateRange(startDate, endDate);
|
|
409
|
+
const trend = [];
|
|
410
|
+
|
|
411
|
+
for (const date of dates) {
|
|
412
|
+
try { sanitizeDate(date); } catch { continue; }
|
|
413
|
+
const entries = await this._parseRequestLogs(appName, date);
|
|
414
|
+
const total = entries.length;
|
|
415
|
+
const errors = entries.filter((e) => e.statusCode >= 400).length;
|
|
416
|
+
const e5xx = entries.filter((e) => e.statusCode >= 500).length;
|
|
417
|
+
|
|
418
|
+
// Top error endpoint this day
|
|
419
|
+
const epErr = {};
|
|
420
|
+
for (const e of entries) {
|
|
421
|
+
if (e.statusCode >= 400) { epErr[e.path] = (epErr[e.path] || 0) + 1; }
|
|
422
|
+
}
|
|
423
|
+
const topErrPath = Object.entries(epErr).sort((a, b) => b[1] - a[1])[0]?.[0] || '';
|
|
424
|
+
|
|
425
|
+
trend.push({
|
|
426
|
+
date,
|
|
427
|
+
totalRequests: total,
|
|
428
|
+
errors,
|
|
429
|
+
errors5xx: e5xx,
|
|
430
|
+
errorRate: total ? ((errors / total) * 100).toFixed(1) : '0.0',
|
|
431
|
+
errorRate5xx: total ? ((e5xx / total) * 100).toFixed(1) : '0.0',
|
|
432
|
+
topErrPath,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
return trend;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ─── Hourly slowness pattern across a date range ──────────────────────────
|
|
439
|
+
// Returns avg duration per hour-of-day (UTC) aggregated across all selected dates.
|
|
440
|
+
// Useful for answering "which hour is my API always slowest?"
|
|
441
|
+
|
|
442
|
+
async getHourlySlowPattern (appName, startDate, endDate) {
|
|
443
|
+
const dates = this._dateRange(startDate, endDate);
|
|
444
|
+
const buckets = Array.from({ length: 24 }, () => ({ durs: [], count: 0 }));
|
|
445
|
+
|
|
446
|
+
for (const date of dates) {
|
|
447
|
+
try { sanitizeDate(date); } catch { continue; }
|
|
448
|
+
const entries = await this._parseRequestLogs(appName, date);
|
|
449
|
+
for (const e of entries) {
|
|
450
|
+
const h = new Date(e.ts).getUTCHours();
|
|
451
|
+
buckets[h].durs.push(e.durationMs || 0);
|
|
452
|
+
buckets[h].count++;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const max = Math.max(...buckets.map((b) => b.durs.length
|
|
457
|
+
? b.durs.reduce((a, x) => a + x, 0) / b.durs.length : 0), 1);
|
|
458
|
+
|
|
459
|
+
return buckets.map((b, hour) => {
|
|
460
|
+
const avg = b.durs.length ? b.durs.reduce((a, x) => a + x, 0) / b.durs.length : 0;
|
|
461
|
+
return {
|
|
462
|
+
hour,
|
|
463
|
+
avgDuration: Math.round(avg * 10) / 10,
|
|
464
|
+
count: b.count,
|
|
465
|
+
intensity: Math.round((avg / max) * 100), // 0–100 for heatmap colouring
|
|
466
|
+
};
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module.exports = ApiAnalyticsService;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const fsP = require('fs').promises;
|
|
4
|
+
const config = require('../config');
|
|
5
|
+
const logger = require('../lib/logger');
|
|
6
|
+
|
|
7
|
+
const VALID_SCOPES = [
|
|
8
|
+
'logs:write',
|
|
9
|
+
'logs:read',
|
|
10
|
+
'analytics:read',
|
|
11
|
+
'health:read',
|
|
12
|
+
'stream:read',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
class ApiKeyService {
|
|
16
|
+
constructor (org) {
|
|
17
|
+
this._org = org || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_file (cfgRef, orgAttr) {
|
|
21
|
+
return this._org && this._org[orgAttr] ? this._org[orgAttr] : cfgRef;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_hash (raw) {
|
|
25
|
+
return crypto.createHash('sha256').update(raw).digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async _load () {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(
|
|
31
|
+
await fsP.readFile(
|
|
32
|
+
this._file(config.API_KEYS_FILE, 'apiKeysFile'),
|
|
33
|
+
'utf8',
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (e.code === 'ENOENT') {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async _save (keys) {
|
|
45
|
+
await fsP.writeFile(
|
|
46
|
+
this._file(config.API_KEYS_FILE, 'apiKeysFile'),
|
|
47
|
+
JSON.stringify(keys, null, 2),
|
|
48
|
+
'utf8',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async list () {
|
|
53
|
+
const keys = await this._load();
|
|
54
|
+
return keys.map((k) => ({ ...k, keyHash: undefined, key: undefined }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async validate (rawKey) {
|
|
58
|
+
if (!rawKey) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const keys = await this._load();
|
|
62
|
+
const hash = this._hash(rawKey);
|
|
63
|
+
const found = keys.find((k) => k.active && k.keyHash === hash);
|
|
64
|
+
if (!found) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (found.expiresAt && new Date(found.expiresAt) < new Date()) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
found.lastUsedAt = new Date().toISOString();
|
|
71
|
+
this._save(keys).catch(() => {});
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async create ({
|
|
76
|
+
name,
|
|
77
|
+
scopes = ['logs:write'],
|
|
78
|
+
expiresAt = null,
|
|
79
|
+
createdBy = 'system',
|
|
80
|
+
}) {
|
|
81
|
+
if (!name || name.length < 2 || name.length > 64) {
|
|
82
|
+
throw Object.assign(new Error('Name must be 2-64 chars'), {
|
|
83
|
+
status: 400,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const bad = (scopes || []).filter((s) => !VALID_SCOPES.includes(s));
|
|
87
|
+
if (bad.length) {
|
|
88
|
+
throw Object.assign(new Error(`Invalid scopes: ${bad.join(',')}`), {
|
|
89
|
+
status: 400,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const keys = await this._load();
|
|
93
|
+
if (keys.find((k) => k.name === name)) {
|
|
94
|
+
throw Object.assign(new Error('Key name already exists'), {
|
|
95
|
+
status: 409,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const raw = `blq_${crypto.randomBytes(32).toString('hex')}`;
|
|
100
|
+
const entry = {
|
|
101
|
+
id: crypto.randomUUID(),
|
|
102
|
+
name,
|
|
103
|
+
keyHash: this._hash(raw),
|
|
104
|
+
keyPrefix: `${raw.slice(0, 14)}…`,
|
|
105
|
+
scopes: scopes || ['logs:write'],
|
|
106
|
+
active: true,
|
|
107
|
+
expiresAt: expiresAt || null,
|
|
108
|
+
createdAt: new Date().toISOString(),
|
|
109
|
+
createdBy,
|
|
110
|
+
lastUsedAt: null,
|
|
111
|
+
};
|
|
112
|
+
keys.push(entry);
|
|
113
|
+
await this._save(keys);
|
|
114
|
+
logger.info(`[ApiKey] Created "${name}" scopes=${scopes.join(',')}`);
|
|
115
|
+
return { ...entry, key: raw, keyHash: undefined };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async revoke (id, actor) {
|
|
119
|
+
const keys = await this._load(),
|
|
120
|
+
idx = keys.findIndex((k) => k.id === id);
|
|
121
|
+
if (idx === -1) {
|
|
122
|
+
throw Object.assign(new Error('Key not found'), { status: 404 });
|
|
123
|
+
}
|
|
124
|
+
keys[idx].active = false;
|
|
125
|
+
await this._save(keys);
|
|
126
|
+
logger.info(`[ApiKey] Revoked "${keys[idx].name}" by ${actor}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async remove (id, actor) {
|
|
130
|
+
const keys = await this._load(),
|
|
131
|
+
k = keys.find((k) => k.id === id);
|
|
132
|
+
if (!k) {
|
|
133
|
+
throw Object.assign(new Error('Key not found'), { status: 404 });
|
|
134
|
+
}
|
|
135
|
+
await this._save(keys.filter((x) => x.id !== id));
|
|
136
|
+
logger.info(`[ApiKey] Deleted "${k.name}" by ${actor}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async update (id, updates, actor) {
|
|
140
|
+
const keys = await this._load(),
|
|
141
|
+
k = keys.find((x) => x.id === id);
|
|
142
|
+
if (!k) {
|
|
143
|
+
throw Object.assign(new Error('Key not found'), { status: 404 });
|
|
144
|
+
}
|
|
145
|
+
if (updates.scopes) {
|
|
146
|
+
const bad = updates.scopes.filter((s) => !VALID_SCOPES.includes(s));
|
|
147
|
+
if (bad.length) {
|
|
148
|
+
throw Object.assign(new Error('Invalid scopes'), { status: 400 });
|
|
149
|
+
}
|
|
150
|
+
k.scopes = updates.scopes;
|
|
151
|
+
}
|
|
152
|
+
if (updates.expiresAt !== undefined) {
|
|
153
|
+
k.expiresAt = updates.expiresAt || null;
|
|
154
|
+
}
|
|
155
|
+
if (updates.active !== undefined) {
|
|
156
|
+
k.active = Boolean(updates.active);
|
|
157
|
+
}
|
|
158
|
+
if (updates.name) {
|
|
159
|
+
k.name = updates.name;
|
|
160
|
+
}
|
|
161
|
+
await this._save(keys);
|
|
162
|
+
logger.info(`[ApiKey] Updated "${k.name}" by ${actor}`);
|
|
163
|
+
return { ...k, keyHash: undefined };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
module.exports = ApiKeyService;
|