express-sequelize-traffic 0.1.0 → 0.2.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/README.md +10 -1
- package/dist/index.cjs +880 -0
- package/dist/index.js +844 -0
- package/package.json +13 -6
- package/examples/express-app.js +0 -106
- package/src/dashboard.js +0 -132
- package/src/index.js +0 -67
- package/src/middleware.js +0 -111
- package/src/models/TrafficLog.js +0 -83
- package/src/realtime.js +0 -71
- package/src/routes/analyticsRoutes.js +0 -83
- package/src/services/analyticsService.js +0 -286
- package/src/utils/dashboardAuth.js +0 -71
- package/src/utils/routeMatcher.js +0 -53
- package/src/utils/safeAsync.js +0 -13
- /package/{src → dist}/dashboard-public/assets/index-CaWHQ-tp.js +0 -0
- /package/{src → dist}/dashboard-public/assets/index-iT93XJlh.css +0 -0
- /package/{src → dist}/dashboard-public/index.html +0 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from "node:url"; const __PACKAGE_DIR__ = __fileURLToPath(new URL(".", import.meta.url));
|
|
2
|
+
|
|
3
|
+
// src/models/TrafficLog.js
|
|
4
|
+
import { DataTypes } from "sequelize";
|
|
5
|
+
function defineTrafficLogModel(sequelize) {
|
|
6
|
+
if (!sequelize) {
|
|
7
|
+
throw new Error("A Sequelize instance is required to define TrafficLog.");
|
|
8
|
+
}
|
|
9
|
+
if (sequelize.models.TrafficLog) {
|
|
10
|
+
return sequelize.models.TrafficLog;
|
|
11
|
+
}
|
|
12
|
+
return sequelize.define(
|
|
13
|
+
"TrafficLog",
|
|
14
|
+
{
|
|
15
|
+
id: {
|
|
16
|
+
type: DataTypes.BIGINT,
|
|
17
|
+
autoIncrement: true,
|
|
18
|
+
primaryKey: true
|
|
19
|
+
},
|
|
20
|
+
userId: {
|
|
21
|
+
type: DataTypes.STRING,
|
|
22
|
+
allowNull: true
|
|
23
|
+
},
|
|
24
|
+
sessionId: {
|
|
25
|
+
type: DataTypes.STRING,
|
|
26
|
+
allowNull: true
|
|
27
|
+
},
|
|
28
|
+
method: {
|
|
29
|
+
type: DataTypes.STRING(16),
|
|
30
|
+
allowNull: false
|
|
31
|
+
},
|
|
32
|
+
route: {
|
|
33
|
+
type: DataTypes.STRING,
|
|
34
|
+
allowNull: false
|
|
35
|
+
},
|
|
36
|
+
originalUrl: {
|
|
37
|
+
type: DataTypes.STRING,
|
|
38
|
+
allowNull: false
|
|
39
|
+
},
|
|
40
|
+
statusCode: {
|
|
41
|
+
type: DataTypes.INTEGER,
|
|
42
|
+
allowNull: false
|
|
43
|
+
},
|
|
44
|
+
durationMs: {
|
|
45
|
+
type: DataTypes.INTEGER,
|
|
46
|
+
allowNull: false
|
|
47
|
+
},
|
|
48
|
+
isSlow: {
|
|
49
|
+
type: DataTypes.BOOLEAN,
|
|
50
|
+
allowNull: false,
|
|
51
|
+
defaultValue: false
|
|
52
|
+
},
|
|
53
|
+
ip: {
|
|
54
|
+
type: DataTypes.STRING,
|
|
55
|
+
allowNull: true
|
|
56
|
+
},
|
|
57
|
+
userAgent: {
|
|
58
|
+
type: DataTypes.TEXT,
|
|
59
|
+
allowNull: true
|
|
60
|
+
},
|
|
61
|
+
startedAt: {
|
|
62
|
+
type: DataTypes.DATE,
|
|
63
|
+
allowNull: false
|
|
64
|
+
},
|
|
65
|
+
endedAt: {
|
|
66
|
+
type: DataTypes.DATE,
|
|
67
|
+
allowNull: false
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
tableName: "traffic_logs",
|
|
72
|
+
indexes: [
|
|
73
|
+
{ fields: ["userId"] },
|
|
74
|
+
{ fields: ["sessionId"] },
|
|
75
|
+
{ fields: ["route"] },
|
|
76
|
+
{ fields: ["method"] },
|
|
77
|
+
{ fields: ["statusCode"] },
|
|
78
|
+
{ fields: ["durationMs"] },
|
|
79
|
+
{ fields: ["createdAt"] }
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/utils/safeAsync.js
|
|
86
|
+
async function safeAsync(task, options = {}) {
|
|
87
|
+
const { fallback = null, onError } = options;
|
|
88
|
+
try {
|
|
89
|
+
return await task();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (typeof onError === "function") {
|
|
92
|
+
onError(error);
|
|
93
|
+
}
|
|
94
|
+
return fallback;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/utils/routeMatcher.js
|
|
99
|
+
function normalizeRouteCandidate(route) {
|
|
100
|
+
if (Array.isArray(route)) {
|
|
101
|
+
return route[0] || "/";
|
|
102
|
+
}
|
|
103
|
+
if (route instanceof RegExp) {
|
|
104
|
+
return route.toString();
|
|
105
|
+
}
|
|
106
|
+
return route || null;
|
|
107
|
+
}
|
|
108
|
+
function resolveTrackedRoute(req) {
|
|
109
|
+
const routePath = normalizeRouteCandidate(req.route?.path);
|
|
110
|
+
if (req.baseUrl && routePath) {
|
|
111
|
+
return routePath === "/" ? req.baseUrl || "/" : `${req.baseUrl}${routePath}`;
|
|
112
|
+
}
|
|
113
|
+
return routePath || req.path || req.originalUrl || "/";
|
|
114
|
+
}
|
|
115
|
+
function matchesIgnoredRoute(matcher, candidate) {
|
|
116
|
+
if (!candidate) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (matcher instanceof RegExp) {
|
|
120
|
+
return matcher.test(candidate);
|
|
121
|
+
}
|
|
122
|
+
if (typeof matcher === "function") {
|
|
123
|
+
return Boolean(matcher(candidate));
|
|
124
|
+
}
|
|
125
|
+
if (typeof matcher === "string") {
|
|
126
|
+
return candidate === matcher || candidate.startsWith(`${matcher}?`);
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
function shouldIgnoreRoute({
|
|
131
|
+
route,
|
|
132
|
+
originalUrl,
|
|
133
|
+
ignoredRoutes = []
|
|
134
|
+
}) {
|
|
135
|
+
return ignoredRoutes.some(
|
|
136
|
+
(matcher) => matchesIgnoredRoute(matcher, route) || matchesIgnoredRoute(matcher, originalUrl)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/middleware.js
|
|
141
|
+
function resolveOptionalValue(getter, req, debug, label) {
|
|
142
|
+
if (typeof getter !== "function") {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
return getter(req) ?? null;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (debug) {
|
|
149
|
+
console.error(
|
|
150
|
+
`[express-sequelize-traffic] Failed to resolve ${label}.`,
|
|
151
|
+
error
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function createLogPayload({
|
|
158
|
+
req,
|
|
159
|
+
res,
|
|
160
|
+
startedAt,
|
|
161
|
+
endedAt,
|
|
162
|
+
slowRouteThresholdMs,
|
|
163
|
+
trackIp,
|
|
164
|
+
trackUserAgent,
|
|
165
|
+
getUserId,
|
|
166
|
+
getSessionId,
|
|
167
|
+
debug
|
|
168
|
+
}) {
|
|
169
|
+
const route = resolveTrackedRoute(req);
|
|
170
|
+
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
|
|
171
|
+
return {
|
|
172
|
+
userId: resolveOptionalValue(getUserId, req, debug, "userId"),
|
|
173
|
+
sessionId: resolveOptionalValue(getSessionId, req, debug, "sessionId"),
|
|
174
|
+
method: req.method,
|
|
175
|
+
route,
|
|
176
|
+
originalUrl: req.originalUrl || route,
|
|
177
|
+
statusCode: res.statusCode,
|
|
178
|
+
durationMs,
|
|
179
|
+
isSlow: durationMs >= slowRouteThresholdMs,
|
|
180
|
+
ip: trackIp ? req.ip || req.socket?.remoteAddress || null : null,
|
|
181
|
+
userAgent: trackUserAgent ? req.get("user-agent") || null : null,
|
|
182
|
+
startedAt,
|
|
183
|
+
endedAt
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function createTrackingMiddleware({
|
|
187
|
+
TrafficLog,
|
|
188
|
+
getUserId,
|
|
189
|
+
getSessionId,
|
|
190
|
+
slowRouteThresholdMs = 1e3,
|
|
191
|
+
ignoredRoutes = [],
|
|
192
|
+
trackIp = false,
|
|
193
|
+
trackUserAgent = true,
|
|
194
|
+
debug = false,
|
|
195
|
+
realtimeBridge
|
|
196
|
+
}) {
|
|
197
|
+
return (req, res, next) => {
|
|
198
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
199
|
+
res.once("finish", () => {
|
|
200
|
+
const endedAt = /* @__PURE__ */ new Date();
|
|
201
|
+
const route = resolveTrackedRoute(req);
|
|
202
|
+
const originalUrl = req.originalUrl || route;
|
|
203
|
+
if (shouldIgnoreRoute({ route, originalUrl, ignoredRoutes })) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
void safeAsync(
|
|
207
|
+
async () => {
|
|
208
|
+
const payload = createLogPayload({
|
|
209
|
+
req,
|
|
210
|
+
res,
|
|
211
|
+
startedAt,
|
|
212
|
+
endedAt,
|
|
213
|
+
slowRouteThresholdMs,
|
|
214
|
+
trackIp,
|
|
215
|
+
trackUserAgent,
|
|
216
|
+
getUserId,
|
|
217
|
+
getSessionId,
|
|
218
|
+
debug
|
|
219
|
+
});
|
|
220
|
+
const createdLog = await TrafficLog.create(payload);
|
|
221
|
+
realtimeBridge?.emitNewRequest(createdLog.get({ plain: true }));
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
onError: (error) => {
|
|
225
|
+
if (debug) {
|
|
226
|
+
console.error(
|
|
227
|
+
"[express-sequelize-traffic] Failed to persist traffic log.",
|
|
228
|
+
error
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
next();
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/services/analyticsService.js
|
|
240
|
+
import { Op, fn, col, literal } from "sequelize";
|
|
241
|
+
function toNumber(value, decimals = 2) {
|
|
242
|
+
const parsed = Number(value ?? 0);
|
|
243
|
+
if (!Number.isFinite(parsed)) {
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
return Number(parsed.toFixed(decimals));
|
|
247
|
+
}
|
|
248
|
+
function normalizeLog(log) {
|
|
249
|
+
return {
|
|
250
|
+
id: log.id,
|
|
251
|
+
userId: log.userId,
|
|
252
|
+
sessionId: log.sessionId,
|
|
253
|
+
method: log.method,
|
|
254
|
+
route: log.route,
|
|
255
|
+
originalUrl: log.originalUrl,
|
|
256
|
+
statusCode: Number(log.statusCode),
|
|
257
|
+
durationMs: Number(log.durationMs),
|
|
258
|
+
isSlow: Boolean(log.isSlow),
|
|
259
|
+
ip: log.ip,
|
|
260
|
+
userAgent: log.userAgent,
|
|
261
|
+
startedAt: log.startedAt,
|
|
262
|
+
endedAt: log.endedAt,
|
|
263
|
+
createdAt: log.createdAt,
|
|
264
|
+
updatedAt: log.updatedAt
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function buildTimeline(logs = []) {
|
|
268
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
269
|
+
for (const log of logs) {
|
|
270
|
+
const bucketDate = new Date(log.createdAt);
|
|
271
|
+
bucketDate.setMinutes(0, 0, 0);
|
|
272
|
+
const bucketKey = bucketDate.toISOString();
|
|
273
|
+
buckets.set(bucketKey, (buckets.get(bucketKey) || 0) + 1);
|
|
274
|
+
}
|
|
275
|
+
return [...buckets.entries()].sort(([left], [right]) => left.localeCompare(right)).slice(-24).map(([timestamp, totalRequests]) => ({
|
|
276
|
+
timestamp,
|
|
277
|
+
totalRequests
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
function buildGroupKey(row) {
|
|
281
|
+
return `${row.route}::${row.method}`;
|
|
282
|
+
}
|
|
283
|
+
function mapGroupedCounts(rows = []) {
|
|
284
|
+
const counts = /* @__PURE__ */ new Map();
|
|
285
|
+
for (const row of rows) {
|
|
286
|
+
counts.set(buildGroupKey(row), Number(row.totalCount || 0));
|
|
287
|
+
}
|
|
288
|
+
return counts;
|
|
289
|
+
}
|
|
290
|
+
function createAnalyticsService(TrafficLog) {
|
|
291
|
+
return {
|
|
292
|
+
async getOverview() {
|
|
293
|
+
const [
|
|
294
|
+
totalRequests,
|
|
295
|
+
averageDurationMsRaw,
|
|
296
|
+
slowRequestCount,
|
|
297
|
+
errorRequestCount,
|
|
298
|
+
uniqueUsers,
|
|
299
|
+
topRoutesRows,
|
|
300
|
+
slowestRoutesRows,
|
|
301
|
+
statusCodeRows,
|
|
302
|
+
latestRequests,
|
|
303
|
+
timelineLogs
|
|
304
|
+
] = await Promise.all([
|
|
305
|
+
TrafficLog.count(),
|
|
306
|
+
TrafficLog.aggregate("durationMs", "avg"),
|
|
307
|
+
TrafficLog.count({ where: { isSlow: true } }),
|
|
308
|
+
TrafficLog.count({
|
|
309
|
+
where: {
|
|
310
|
+
statusCode: { [Op.gte]: 400 }
|
|
311
|
+
}
|
|
312
|
+
}),
|
|
313
|
+
TrafficLog.count({
|
|
314
|
+
distinct: true,
|
|
315
|
+
col: "userId",
|
|
316
|
+
where: {
|
|
317
|
+
userId: { [Op.not]: null }
|
|
318
|
+
}
|
|
319
|
+
}),
|
|
320
|
+
TrafficLog.findAll({
|
|
321
|
+
attributes: [
|
|
322
|
+
"route",
|
|
323
|
+
[fn("COUNT", col("id")), "totalRequests"]
|
|
324
|
+
],
|
|
325
|
+
where: {
|
|
326
|
+
route: { [Op.not]: null }
|
|
327
|
+
},
|
|
328
|
+
group: ["route"],
|
|
329
|
+
order: [[literal("totalRequests"), "DESC"]],
|
|
330
|
+
limit: 5,
|
|
331
|
+
raw: true
|
|
332
|
+
}),
|
|
333
|
+
TrafficLog.findAll({
|
|
334
|
+
attributes: [
|
|
335
|
+
"route",
|
|
336
|
+
"method",
|
|
337
|
+
[fn("AVG", col("durationMs")), "averageDurationMs"],
|
|
338
|
+
[fn("MAX", col("durationMs")), "maxDurationMs"],
|
|
339
|
+
[fn("COUNT", col("id")), "totalRequests"]
|
|
340
|
+
],
|
|
341
|
+
where: {
|
|
342
|
+
route: { [Op.not]: null }
|
|
343
|
+
},
|
|
344
|
+
group: ["route", "method"],
|
|
345
|
+
order: [[literal("averageDurationMs"), "DESC"]],
|
|
346
|
+
limit: 5,
|
|
347
|
+
raw: true
|
|
348
|
+
}),
|
|
349
|
+
TrafficLog.findAll({
|
|
350
|
+
attributes: [
|
|
351
|
+
"statusCode",
|
|
352
|
+
[fn("COUNT", col("id")), "totalRequests"]
|
|
353
|
+
],
|
|
354
|
+
group: ["statusCode"],
|
|
355
|
+
order: [["statusCode", "ASC"]],
|
|
356
|
+
raw: true
|
|
357
|
+
}),
|
|
358
|
+
TrafficLog.findAll({
|
|
359
|
+
order: [["createdAt", "DESC"]],
|
|
360
|
+
limit: 10,
|
|
361
|
+
raw: true
|
|
362
|
+
}),
|
|
363
|
+
TrafficLog.findAll({
|
|
364
|
+
attributes: ["createdAt"],
|
|
365
|
+
order: [["createdAt", "DESC"]],
|
|
366
|
+
limit: 1e3,
|
|
367
|
+
raw: true
|
|
368
|
+
})
|
|
369
|
+
]);
|
|
370
|
+
return {
|
|
371
|
+
totalRequests,
|
|
372
|
+
averageDurationMs: toNumber(averageDurationMsRaw),
|
|
373
|
+
slowRequestCount,
|
|
374
|
+
errorRequestCount,
|
|
375
|
+
uniqueUsers,
|
|
376
|
+
topRoutes: topRoutesRows.map((row) => ({
|
|
377
|
+
route: row.route,
|
|
378
|
+
totalRequests: Number(row.totalRequests)
|
|
379
|
+
})),
|
|
380
|
+
slowestRoutes: slowestRoutesRows.map((row) => ({
|
|
381
|
+
route: row.route,
|
|
382
|
+
method: row.method,
|
|
383
|
+
totalRequests: Number(row.totalRequests),
|
|
384
|
+
averageDurationMs: toNumber(row.averageDurationMs),
|
|
385
|
+
maxDurationMs: Number(row.maxDurationMs)
|
|
386
|
+
})),
|
|
387
|
+
statusCodeSummary: statusCodeRows.map((row) => ({
|
|
388
|
+
statusCode: Number(row.statusCode),
|
|
389
|
+
totalRequests: Number(row.totalRequests)
|
|
390
|
+
})),
|
|
391
|
+
latestRequests: latestRequests.map(normalizeLog),
|
|
392
|
+
requestsTimeline: buildTimeline(timelineLogs)
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
async getLive() {
|
|
396
|
+
const logs = await TrafficLog.findAll({
|
|
397
|
+
order: [["createdAt", "DESC"]],
|
|
398
|
+
limit: 100,
|
|
399
|
+
raw: true
|
|
400
|
+
});
|
|
401
|
+
return logs.map(normalizeLog);
|
|
402
|
+
},
|
|
403
|
+
async getRoutes() {
|
|
404
|
+
const [baseRows, errorRows, slowRows] = await Promise.all([
|
|
405
|
+
TrafficLog.findAll({
|
|
406
|
+
attributes: [
|
|
407
|
+
"route",
|
|
408
|
+
"method",
|
|
409
|
+
[fn("COUNT", col("id")), "totalRequests"],
|
|
410
|
+
[fn("AVG", col("durationMs")), "averageDurationMs"],
|
|
411
|
+
[fn("MAX", col("durationMs")), "maxDurationMs"]
|
|
412
|
+
],
|
|
413
|
+
where: {
|
|
414
|
+
route: { [Op.not]: null }
|
|
415
|
+
},
|
|
416
|
+
group: ["route", "method"],
|
|
417
|
+
order: [[literal("averageDurationMs"), "DESC"]],
|
|
418
|
+
raw: true
|
|
419
|
+
}),
|
|
420
|
+
TrafficLog.findAll({
|
|
421
|
+
attributes: [
|
|
422
|
+
"route",
|
|
423
|
+
"method",
|
|
424
|
+
[fn("COUNT", col("id")), "totalCount"]
|
|
425
|
+
],
|
|
426
|
+
where: {
|
|
427
|
+
route: { [Op.not]: null },
|
|
428
|
+
statusCode: { [Op.gte]: 400 }
|
|
429
|
+
},
|
|
430
|
+
group: ["route", "method"],
|
|
431
|
+
raw: true
|
|
432
|
+
}),
|
|
433
|
+
TrafficLog.findAll({
|
|
434
|
+
attributes: [
|
|
435
|
+
"route",
|
|
436
|
+
"method",
|
|
437
|
+
[fn("COUNT", col("id")), "totalCount"]
|
|
438
|
+
],
|
|
439
|
+
where: {
|
|
440
|
+
route: { [Op.not]: null },
|
|
441
|
+
isSlow: true
|
|
442
|
+
},
|
|
443
|
+
group: ["route", "method"],
|
|
444
|
+
raw: true
|
|
445
|
+
})
|
|
446
|
+
]);
|
|
447
|
+
const errorCounts = mapGroupedCounts(errorRows);
|
|
448
|
+
const slowCounts = mapGroupedCounts(slowRows);
|
|
449
|
+
return baseRows.map((row) => {
|
|
450
|
+
const key = buildGroupKey(row);
|
|
451
|
+
return {
|
|
452
|
+
route: row.route,
|
|
453
|
+
method: row.method,
|
|
454
|
+
totalRequests: Number(row.totalRequests),
|
|
455
|
+
averageDurationMs: toNumber(row.averageDurationMs),
|
|
456
|
+
maxDurationMs: Number(row.maxDurationMs),
|
|
457
|
+
errorCount: errorCounts.get(key) || 0,
|
|
458
|
+
slowCount: slowCounts.get(key) || 0
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
async getUsers() {
|
|
463
|
+
const rows = await TrafficLog.findAll({
|
|
464
|
+
attributes: [
|
|
465
|
+
"userId",
|
|
466
|
+
[fn("COUNT", col("id")), "totalRequests"],
|
|
467
|
+
[fn("MAX", col("createdAt")), "lastSeenAt"],
|
|
468
|
+
[fn("AVG", col("durationMs")), "averageDurationMs"]
|
|
469
|
+
],
|
|
470
|
+
where: {
|
|
471
|
+
userId: { [Op.not]: null }
|
|
472
|
+
},
|
|
473
|
+
group: ["userId"],
|
|
474
|
+
order: [[literal("totalRequests"), "DESC"]],
|
|
475
|
+
raw: true
|
|
476
|
+
});
|
|
477
|
+
return rows.map((row) => ({
|
|
478
|
+
userId: row.userId,
|
|
479
|
+
totalRequests: Number(row.totalRequests),
|
|
480
|
+
lastSeenAt: row.lastSeenAt,
|
|
481
|
+
averageDurationMs: toNumber(row.averageDurationMs)
|
|
482
|
+
}));
|
|
483
|
+
},
|
|
484
|
+
async getErrors() {
|
|
485
|
+
const rows = await TrafficLog.findAll({
|
|
486
|
+
where: {
|
|
487
|
+
statusCode: { [Op.gte]: 400 }
|
|
488
|
+
},
|
|
489
|
+
order: [["createdAt", "DESC"]],
|
|
490
|
+
limit: 100,
|
|
491
|
+
raw: true
|
|
492
|
+
});
|
|
493
|
+
return rows.map(normalizeLog);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/dashboard.js
|
|
499
|
+
import fs from "node:fs";
|
|
500
|
+
import path from "node:path";
|
|
501
|
+
import express2 from "express";
|
|
502
|
+
|
|
503
|
+
// src/routes/analyticsRoutes.js
|
|
504
|
+
import express from "express";
|
|
505
|
+
function sendApiError(res, message) {
|
|
506
|
+
res.status(500).json({ error: message });
|
|
507
|
+
}
|
|
508
|
+
function createAnalyticsRouter({ analyticsService, debug = false }) {
|
|
509
|
+
const router = express.Router();
|
|
510
|
+
const logApiError = (scope) => (error) => {
|
|
511
|
+
if (debug) {
|
|
512
|
+
console.error(`[express-sequelize-traffic] ${scope}`, error);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
router.get("/overview", async (_req, res) => {
|
|
516
|
+
const data = await safeAsync(() => analyticsService.getOverview(), {
|
|
517
|
+
onError: logApiError("Failed to load overview analytics.")
|
|
518
|
+
});
|
|
519
|
+
if (!data) {
|
|
520
|
+
sendApiError(res, "Unable to load overview analytics.");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
res.json(data);
|
|
524
|
+
});
|
|
525
|
+
router.get("/live", async (_req, res) => {
|
|
526
|
+
const data = await safeAsync(() => analyticsService.getLive(), {
|
|
527
|
+
onError: logApiError("Failed to load live traffic logs.")
|
|
528
|
+
});
|
|
529
|
+
if (!data) {
|
|
530
|
+
sendApiError(res, "Unable to load live traffic logs.");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
res.json(data);
|
|
534
|
+
});
|
|
535
|
+
router.get("/routes", async (_req, res) => {
|
|
536
|
+
const data = await safeAsync(() => analyticsService.getRoutes(), {
|
|
537
|
+
onError: logApiError("Failed to load route analytics.")
|
|
538
|
+
});
|
|
539
|
+
if (!data) {
|
|
540
|
+
sendApiError(res, "Unable to load route analytics.");
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
res.json(data);
|
|
544
|
+
});
|
|
545
|
+
router.get("/users", async (_req, res) => {
|
|
546
|
+
const data = await safeAsync(() => analyticsService.getUsers(), {
|
|
547
|
+
onError: logApiError("Failed to load user analytics.")
|
|
548
|
+
});
|
|
549
|
+
if (!data) {
|
|
550
|
+
sendApiError(res, "Unable to load user analytics.");
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
res.json(data);
|
|
554
|
+
});
|
|
555
|
+
router.get("/errors", async (_req, res) => {
|
|
556
|
+
const data = await safeAsync(() => analyticsService.getErrors(), {
|
|
557
|
+
onError: logApiError("Failed to load error analytics.")
|
|
558
|
+
});
|
|
559
|
+
if (!data) {
|
|
560
|
+
sendApiError(res, "Unable to load error analytics.");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
res.json(data);
|
|
564
|
+
});
|
|
565
|
+
return router;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/utils/dashboardAuth.js
|
|
569
|
+
function parseBasicAuthHeader(headerValue) {
|
|
570
|
+
if (!headerValue || !headerValue.startsWith("Basic ")) {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
const decoded = Buffer.from(headerValue.slice(6), "base64").toString(
|
|
575
|
+
"utf8"
|
|
576
|
+
);
|
|
577
|
+
const separatorIndex = decoded.indexOf(":");
|
|
578
|
+
if (separatorIndex === -1) {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
username: decoded.slice(0, separatorIndex),
|
|
583
|
+
password: decoded.slice(separatorIndex + 1)
|
|
584
|
+
};
|
|
585
|
+
} catch (_error) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
function isDashboardAuthEnabled(dashboard = {}) {
|
|
590
|
+
return Boolean(dashboard.enabled && dashboard.username && dashboard.password);
|
|
591
|
+
}
|
|
592
|
+
function createBasicAuthMiddleware(dashboard = {}) {
|
|
593
|
+
if (!isDashboardAuthEnabled(dashboard)) {
|
|
594
|
+
return (_req, _res, next) => next();
|
|
595
|
+
}
|
|
596
|
+
return (req, res, next) => {
|
|
597
|
+
const credentials = parseBasicAuthHeader(req.headers.authorization);
|
|
598
|
+
if (credentials?.username === dashboard.username && credentials?.password === dashboard.password) {
|
|
599
|
+
next();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
res.setHeader("WWW-Authenticate", 'Basic realm="Traffic Dashboard"');
|
|
603
|
+
res.status(401).json({ error: "Dashboard authentication required." });
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function createSocketAuthMiddleware(dashboard = {}) {
|
|
607
|
+
return (socket, next) => {
|
|
608
|
+
if (!isDashboardAuthEnabled(dashboard)) {
|
|
609
|
+
next();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const credentials = parseBasicAuthHeader(
|
|
613
|
+
socket.handshake.headers.authorization
|
|
614
|
+
);
|
|
615
|
+
if (credentials?.username === dashboard.username && credentials?.password === dashboard.password) {
|
|
616
|
+
next();
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
next(new Error("Dashboard authentication required."));
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/dashboard.js
|
|
624
|
+
var dashboardBuildDirectory = path.resolve(
|
|
625
|
+
__PACKAGE_DIR__,
|
|
626
|
+
"dashboard-public"
|
|
627
|
+
);
|
|
628
|
+
var dashboardIndexFile = path.join(dashboardBuildDirectory, "index.html");
|
|
629
|
+
function createMissingBuildPage() {
|
|
630
|
+
return `<!doctype html>
|
|
631
|
+
<html lang="en">
|
|
632
|
+
<head>
|
|
633
|
+
<meta charset="utf-8" />
|
|
634
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
635
|
+
<title>Traffic Dashboard Build Missing</title>
|
|
636
|
+
<style>
|
|
637
|
+
body {
|
|
638
|
+
margin: 0;
|
|
639
|
+
font-family: "Segoe UI", sans-serif;
|
|
640
|
+
background: linear-gradient(135deg, #0f172a, #1e293b);
|
|
641
|
+
color: #e2e8f0;
|
|
642
|
+
min-height: 100vh;
|
|
643
|
+
display: grid;
|
|
644
|
+
place-items: center;
|
|
645
|
+
padding: 24px;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.panel {
|
|
649
|
+
max-width: 640px;
|
|
650
|
+
background: rgba(15, 23, 42, 0.9);
|
|
651
|
+
border: 1px solid rgba(148, 163, 184, 0.2);
|
|
652
|
+
border-radius: 24px;
|
|
653
|
+
padding: 32px;
|
|
654
|
+
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.45);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
h1 {
|
|
658
|
+
margin-top: 0;
|
|
659
|
+
font-size: 2rem;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
code {
|
|
663
|
+
background: rgba(148, 163, 184, 0.16);
|
|
664
|
+
padding: 2px 6px;
|
|
665
|
+
border-radius: 6px;
|
|
666
|
+
}
|
|
667
|
+
</style>
|
|
668
|
+
</head>
|
|
669
|
+
<body>
|
|
670
|
+
<div class="panel">
|
|
671
|
+
<h1>Dashboard build not found</h1>
|
|
672
|
+
<p>
|
|
673
|
+
The analytics APIs are available, but the Vite dashboard has not been built
|
|
674
|
+
into <code>dist/dashboard-public</code> yet.
|
|
675
|
+
</p>
|
|
676
|
+
<p>Run <code>npm run build:dashboard</code> in this package to generate the static dashboard assets.</p>
|
|
677
|
+
</div>
|
|
678
|
+
</body>
|
|
679
|
+
</html>`;
|
|
680
|
+
}
|
|
681
|
+
function createDisabledDashboardRouter() {
|
|
682
|
+
const router = express2.Router();
|
|
683
|
+
router.use("/api", (_req, res) => {
|
|
684
|
+
res.status(404).json({ error: "Dashboard is disabled." });
|
|
685
|
+
});
|
|
686
|
+
router.use((_req, res) => {
|
|
687
|
+
res.status(404).send("The traffic dashboard is disabled for this tracker instance.");
|
|
688
|
+
});
|
|
689
|
+
return router;
|
|
690
|
+
}
|
|
691
|
+
function createDashboardRouter({
|
|
692
|
+
analyticsService,
|
|
693
|
+
dashboard = {},
|
|
694
|
+
debug = false
|
|
695
|
+
}) {
|
|
696
|
+
const router = express2.Router();
|
|
697
|
+
router.use(createBasicAuthMiddleware(dashboard));
|
|
698
|
+
router.use("/api", createAnalyticsRouter({ analyticsService, debug }));
|
|
699
|
+
const hasBuiltDashboard = fs.existsSync(dashboardIndexFile);
|
|
700
|
+
if (hasBuiltDashboard) {
|
|
701
|
+
router.get("/", (req, res, next) => {
|
|
702
|
+
if (!req.originalUrl.endsWith("/")) {
|
|
703
|
+
res.redirect(302, `${req.baseUrl}/`);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
next();
|
|
707
|
+
});
|
|
708
|
+
router.use(
|
|
709
|
+
express2.static(dashboardBuildDirectory, {
|
|
710
|
+
index: false
|
|
711
|
+
})
|
|
712
|
+
);
|
|
713
|
+
router.get("*", (req, res, next) => {
|
|
714
|
+
if (req.path.startsWith("/api")) {
|
|
715
|
+
next();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
res.sendFile(dashboardIndexFile);
|
|
719
|
+
});
|
|
720
|
+
return router;
|
|
721
|
+
}
|
|
722
|
+
router.get("*", (req, res, next) => {
|
|
723
|
+
if (req.path.startsWith("/api")) {
|
|
724
|
+
next();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
res.status(503).send(createMissingBuildPage());
|
|
728
|
+
});
|
|
729
|
+
return router;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/realtime.js
|
|
733
|
+
import { Server } from "socket.io";
|
|
734
|
+
function normalizeSocketPath(dashboard = {}) {
|
|
735
|
+
const mountPath = dashboard.mountPath || "/traffic-dashboard";
|
|
736
|
+
const sanitizedMountPath = mountPath.startsWith("/") ? mountPath : `/${mountPath}`;
|
|
737
|
+
return `${sanitizedMountPath.replace(/\/$/, "")}/socket.io`;
|
|
738
|
+
}
|
|
739
|
+
function buildRealtimePayload(log) {
|
|
740
|
+
return {
|
|
741
|
+
userId: log.userId,
|
|
742
|
+
sessionId: log.sessionId,
|
|
743
|
+
method: log.method,
|
|
744
|
+
route: log.route,
|
|
745
|
+
originalUrl: log.originalUrl,
|
|
746
|
+
statusCode: Number(log.statusCode),
|
|
747
|
+
durationMs: Number(log.durationMs),
|
|
748
|
+
isSlow: Boolean(log.isSlow),
|
|
749
|
+
createdAt: log.createdAt
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function createRealtimeBridge({ dashboard = {}, debug = false } = {}) {
|
|
753
|
+
let io = null;
|
|
754
|
+
const socketPath = normalizeSocketPath(dashboard);
|
|
755
|
+
return {
|
|
756
|
+
attachRealtime(server) {
|
|
757
|
+
if (!server) {
|
|
758
|
+
throw new Error("An HTTP server instance is required for realtime.");
|
|
759
|
+
}
|
|
760
|
+
if (io) {
|
|
761
|
+
return io;
|
|
762
|
+
}
|
|
763
|
+
io = new Server(server, {
|
|
764
|
+
path: socketPath,
|
|
765
|
+
serveClient: false
|
|
766
|
+
});
|
|
767
|
+
io.use(createSocketAuthMiddleware(dashboard));
|
|
768
|
+
if (debug) {
|
|
769
|
+
io.on("connection", (socket) => {
|
|
770
|
+
console.info(
|
|
771
|
+
`[express-sequelize-traffic] Dashboard realtime connected: ${socket.id}`
|
|
772
|
+
);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
return io;
|
|
776
|
+
},
|
|
777
|
+
emitNewRequest(log) {
|
|
778
|
+
if (!io) {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
io.emit("traffic:new-request", buildRealtimePayload(log));
|
|
782
|
+
},
|
|
783
|
+
getSocketPath() {
|
|
784
|
+
return socketPath;
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// src/index.js
|
|
790
|
+
function createTrafficTracker(options = {}) {
|
|
791
|
+
const {
|
|
792
|
+
sequelize,
|
|
793
|
+
getUserId,
|
|
794
|
+
getSessionId,
|
|
795
|
+
slowRouteThresholdMs = 1e3,
|
|
796
|
+
ignoredRoutes = [],
|
|
797
|
+
trackIp = false,
|
|
798
|
+
trackUserAgent = true,
|
|
799
|
+
dashboard = {},
|
|
800
|
+
debug = false
|
|
801
|
+
} = options;
|
|
802
|
+
if (!sequelize) {
|
|
803
|
+
throw new Error(
|
|
804
|
+
"createTrafficTracker requires a Sequelize instance via options.sequelize."
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
const TrafficLog = defineTrafficLogModel(sequelize);
|
|
808
|
+
const analyticsService = createAnalyticsService(TrafficLog);
|
|
809
|
+
const realtimeBridge = createRealtimeBridge({ dashboard, debug });
|
|
810
|
+
return {
|
|
811
|
+
middleware: createTrackingMiddleware({
|
|
812
|
+
TrafficLog,
|
|
813
|
+
getUserId,
|
|
814
|
+
getSessionId,
|
|
815
|
+
slowRouteThresholdMs,
|
|
816
|
+
ignoredRoutes,
|
|
817
|
+
trackIp,
|
|
818
|
+
trackUserAgent,
|
|
819
|
+
debug,
|
|
820
|
+
realtimeBridge
|
|
821
|
+
}),
|
|
822
|
+
dashboard: dashboard.enabled ? createDashboardRouter({
|
|
823
|
+
analyticsService,
|
|
824
|
+
dashboard,
|
|
825
|
+
debug
|
|
826
|
+
}) : createDisabledDashboardRouter(),
|
|
827
|
+
attachRealtime(server) {
|
|
828
|
+
return realtimeBridge.attachRealtime(server);
|
|
829
|
+
},
|
|
830
|
+
async sync(syncOptions = {}) {
|
|
831
|
+
return TrafficLog.sync(syncOptions);
|
|
832
|
+
},
|
|
833
|
+
getModel() {
|
|
834
|
+
return TrafficLog;
|
|
835
|
+
},
|
|
836
|
+
getSocketPath() {
|
|
837
|
+
return realtimeBridge.getSocketPath();
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
export {
|
|
842
|
+
createTrafficTracker,
|
|
843
|
+
defineTrafficLogModel
|
|
844
|
+
};
|