hearald 0.0.1
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/.eslintrc +14 -0
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/TODO.md +34 -0
- package/package.json +45 -0
- package/public/analytics.html +20 -0
- package/public/css/analytics.css +381 -0
- package/public/js/.eslintrc +9 -0
- package/public/js/App.js +321 -0
- package/public/js/Tile.js +112 -0
- package/public/js/index.js +9 -0
- package/public/js/util.js +111 -0
- package/rollup.config.js +59 -0
- package/server/analytics.js +321 -0
- package/server/server.js +48 -0
- package/server/serverEvents.js +105 -0
- package/server/serverTrackError.js +47 -0
- package/shared/events.js +44 -0
- package/shared/utils.js +51 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Analytics router and helpers for hearald
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { readFile, writeFile } from "fs/promises";
|
|
6
|
+
import express from "express";
|
|
7
|
+
import {
|
|
8
|
+
omitProperties,
|
|
9
|
+
iso,
|
|
10
|
+
defaultEndDate,
|
|
11
|
+
nowAsPstDate,
|
|
12
|
+
} from "../shared/utils.js";
|
|
13
|
+
import { loadEventsOverWindow } from "./serverEvents.js";
|
|
14
|
+
|
|
15
|
+
export function generateRawMetrics(events, rawMetricDefinitions) {
|
|
16
|
+
const metrics = {};
|
|
17
|
+
Object.entries(rawMetricDefinitions).forEach(
|
|
18
|
+
([name, rawMetricDefinition]) => {
|
|
19
|
+
if (rawMetricDefinition.aggregator === "count") {
|
|
20
|
+
metrics[name] = 0;
|
|
21
|
+
} else if (rawMetricDefinition.aggregator === "set_of_users") {
|
|
22
|
+
metrics[name] = new Set();
|
|
23
|
+
} else if (rawMetricDefinition.aggregator === "histogram") {
|
|
24
|
+
metrics[name] = {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
events.forEach(({ e, p, u }) => {
|
|
30
|
+
Object.entries(rawMetricDefinitions).forEach(
|
|
31
|
+
([name, rawMetricDefinition]) => {
|
|
32
|
+
if (rawMetricDefinition.filter) {
|
|
33
|
+
const match = Object.entries(rawMetricDefinition.filter).every(
|
|
34
|
+
([filterField, filterValue]) => p[filterField] === filterValue
|
|
35
|
+
);
|
|
36
|
+
if (!match) return;
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
rawMetricDefinition.aggregator === "count" &&
|
|
40
|
+
rawMetricDefinition.event === e
|
|
41
|
+
) {
|
|
42
|
+
metrics[name] += 1;
|
|
43
|
+
} else if (rawMetricDefinition.aggregator === "set_of_users") {
|
|
44
|
+
metrics[name].add(u);
|
|
45
|
+
} else if (
|
|
46
|
+
rawMetricDefinition.aggregator === "histogram" &&
|
|
47
|
+
rawMetricDefinition.event === e
|
|
48
|
+
) {
|
|
49
|
+
const field = rawMetricDefinition.field;
|
|
50
|
+
const value = p && p[field] ? p[field] : null;
|
|
51
|
+
if (value !== null) {
|
|
52
|
+
metrics[name][value] = (metrics[name][value] || 0) + 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
// Convert histogram objects to sorted list of [label, count] tuples
|
|
59
|
+
Object.entries(rawMetricDefinitions).forEach(([name, def]) => {
|
|
60
|
+
if (def.aggregator === "histogram") {
|
|
61
|
+
const hist = metrics[name];
|
|
62
|
+
metrics[name] = Object.entries(hist).sort((a, b) => b[1] - a[1]);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return metrics;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function generateComputedMetric(metrics, definition, timeWindow) {
|
|
69
|
+
const set = new Set();
|
|
70
|
+
let denominator;
|
|
71
|
+
switch (definition.formula) {
|
|
72
|
+
case "set_difference":
|
|
73
|
+
(metrics[definition.metrics.at(0)] || new Set()).forEach((v) =>
|
|
74
|
+
set.add(v)
|
|
75
|
+
);
|
|
76
|
+
definition.metrics.slice(1).forEach((metricName) => {
|
|
77
|
+
(metrics[metricName] || new Set()).forEach((v) => set.delete(v));
|
|
78
|
+
});
|
|
79
|
+
return set;
|
|
80
|
+
case "set_count":
|
|
81
|
+
return (metrics[definition.metrics.at(0)] || new Set()).size;
|
|
82
|
+
case "percent":
|
|
83
|
+
denominator = metrics[definition.metrics.at(1)];
|
|
84
|
+
if (!denominator) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
return (100 * (metrics[definition.metrics.at(0)] || 0)) / denominator;
|
|
88
|
+
case "rate_time_window":
|
|
89
|
+
return (metrics[definition.metrics.at(0)] || 0) / timeWindow;
|
|
90
|
+
case "histogram": {
|
|
91
|
+
// Compute histogram for given event type and field
|
|
92
|
+
const sourceEvent = definition.source;
|
|
93
|
+
const field = definition.field;
|
|
94
|
+
const histogram = {};
|
|
95
|
+
// Find all events of the given type
|
|
96
|
+
(metrics._allEvents || []).forEach((ev) => {
|
|
97
|
+
if (ev.e === sourceEvent && ev.p && ev.p[field]) {
|
|
98
|
+
const value = ev.p[field];
|
|
99
|
+
histogram[value] = (histogram[value] || 0) + 1;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
return histogram;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function cacheFilePath(endDate, timeWindow) {
|
|
108
|
+
const endDateIso = typeof endDate === "string" ? endDate : iso(endDate);
|
|
109
|
+
return `events/summary:${endDateIso}:${timeWindow}.json`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function readMetricsFromCache(endDateIso, timeWindow) {
|
|
113
|
+
const filePath = cacheFilePath(endDateIso, timeWindow);
|
|
114
|
+
try {
|
|
115
|
+
return (await readFile(filePath)).toString("utf8");
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if (e.message.indexOf("ENOENT") !== -1) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function writeMetricsToCache(endDateIso, timeWindow, statsJson) {
|
|
125
|
+
const filePath = cacheFilePath(endDateIso, timeWindow);
|
|
126
|
+
return writeFile(filePath, statsJson);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function generateStats(statDefinitions, endDate, timeWindow) {
|
|
130
|
+
const rawMetricDefinitions = statDefinitions.raw_metrics;
|
|
131
|
+
const computedMetricDefinitions = statDefinitions.computed_metrics;
|
|
132
|
+
|
|
133
|
+
// Collect all file suffixes needed for metrics (from raw metrics now)
|
|
134
|
+
const fileSuffixes = new Set([""]); // default log file
|
|
135
|
+
Object.values(rawMetricDefinitions).forEach((def) => {
|
|
136
|
+
if (def.source_file_suffix) fileSuffixes.add(def.source_file_suffix);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Load events from all relevant files for raw metrics
|
|
140
|
+
let allEvents = [];
|
|
141
|
+
const eventsBySuffix = {};
|
|
142
|
+
for (const suffix of fileSuffixes) {
|
|
143
|
+
const events = await loadEventsOverWindow(
|
|
144
|
+
endDate,
|
|
145
|
+
timeWindow,
|
|
146
|
+
suffix || undefined
|
|
147
|
+
);
|
|
148
|
+
allEvents = allEvents.concat(events);
|
|
149
|
+
eventsBySuffix[suffix || ""] = events;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Generate raw metrics, using correct event set for each metric
|
|
153
|
+
const metrics = {};
|
|
154
|
+
Object.entries(rawMetricDefinitions).forEach(([name, def]) => {
|
|
155
|
+
const suffix = def.source_file_suffix || "";
|
|
156
|
+
metrics[name] = generateRawMetrics(eventsBySuffix[suffix], { [name]: def })[
|
|
157
|
+
name
|
|
158
|
+
];
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Store all events for computed metrics
|
|
162
|
+
metrics._allEvents = allEvents;
|
|
163
|
+
|
|
164
|
+
let anyComputed = true;
|
|
165
|
+
while (anyComputed) {
|
|
166
|
+
anyComputed = false;
|
|
167
|
+
for (const [name, computedMetricDefinition] of Object.entries(
|
|
168
|
+
computedMetricDefinitions
|
|
169
|
+
)) {
|
|
170
|
+
if (typeof metrics[name] !== "undefined") {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
computedMetricDefinition.metrics &&
|
|
175
|
+
computedMetricDefinition.metrics.every(
|
|
176
|
+
(m) => typeof metrics[m] !== "undefined"
|
|
177
|
+
)
|
|
178
|
+
) {
|
|
179
|
+
metrics[name] = generateComputedMetric(
|
|
180
|
+
metrics,
|
|
181
|
+
computedMetricDefinition,
|
|
182
|
+
timeWindow
|
|
183
|
+
);
|
|
184
|
+
anyComputed = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const hiddenMetrics = []
|
|
189
|
+
.concat(
|
|
190
|
+
Object.keys(rawMetricDefinitions).filter(
|
|
191
|
+
(k) => rawMetricDefinitions[k].hidden
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
.concat(
|
|
195
|
+
Object.keys(computedMetricDefinitions).filter(
|
|
196
|
+
(k) => computedMetricDefinitions[k].hidden
|
|
197
|
+
)
|
|
198
|
+
);
|
|
199
|
+
const flatMetrics = omitProperties(metrics, ...hiddenMetrics, "_allEvents");
|
|
200
|
+
const nestedMetrics = {};
|
|
201
|
+
Object.entries(flatMetrics).forEach(([metricName, metricValue]) => {
|
|
202
|
+
const metricNameParts = metricName.split(".");
|
|
203
|
+
let m = nestedMetrics;
|
|
204
|
+
while (metricNameParts.length > 1) {
|
|
205
|
+
const metricNamePart = metricNameParts.shift();
|
|
206
|
+
if (typeof m[metricNamePart] === "undefined") {
|
|
207
|
+
m[metricNamePart] = {};
|
|
208
|
+
}
|
|
209
|
+
m = m[metricNamePart];
|
|
210
|
+
}
|
|
211
|
+
m[metricNameParts[0]] = metricValue;
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
metrics: nestedMetrics,
|
|
215
|
+
endDate: iso(endDate),
|
|
216
|
+
timeWindow,
|
|
217
|
+
processedEventCount: allEvents.length,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function handleAnalyticsDataRequest({
|
|
222
|
+
request,
|
|
223
|
+
response,
|
|
224
|
+
statDefinitions,
|
|
225
|
+
}) {
|
|
226
|
+
const defaultEndDateIso = iso(defaultEndDate(nowAsPstDate));
|
|
227
|
+
const endDateIso = request.params.date
|
|
228
|
+
? request.params.date
|
|
229
|
+
: defaultEndDateIso;
|
|
230
|
+
const timeWindow = parseInt(request.params.last, 10);
|
|
231
|
+
if (!timeWindow) {
|
|
232
|
+
response.status(500).json({ Error: "invalid time window" });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const dataFromFile = await readMetricsFromCache(endDateIso, timeWindow);
|
|
236
|
+
if (dataFromFile) {
|
|
237
|
+
console.log(
|
|
238
|
+
`Analytics for ${timeWindow} days ending on ${endDateIso} returned from cache`
|
|
239
|
+
);
|
|
240
|
+
response.status(200).type("application/json").send(dataFromFile);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
console.log(
|
|
244
|
+
`Analytics for ${timeWindow} days ending on ${endDateIso} computing from events`
|
|
245
|
+
);
|
|
246
|
+
try {
|
|
247
|
+
const stats = await generateStats(
|
|
248
|
+
statDefinitions,
|
|
249
|
+
new Date(endDateIso),
|
|
250
|
+
timeWindow
|
|
251
|
+
);
|
|
252
|
+
await writeMetricsToCache(
|
|
253
|
+
endDateIso,
|
|
254
|
+
timeWindow,
|
|
255
|
+
JSON.stringify(stats, null, 2)
|
|
256
|
+
);
|
|
257
|
+
response.status(200).type("application/json").send(JSON.stringify(stats));
|
|
258
|
+
} catch (e) {
|
|
259
|
+
console.error("failed to compute analytics", e);
|
|
260
|
+
response
|
|
261
|
+
.status(500)
|
|
262
|
+
.type("application/json")
|
|
263
|
+
.send(JSON.stringify({ error: e.message }));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
268
|
+
const __dirname = path.dirname(__filename);
|
|
269
|
+
|
|
270
|
+
console.log("filename is", __filename);
|
|
271
|
+
console.log("dirname is", __dirname);
|
|
272
|
+
|
|
273
|
+
export function createAnalyticsRouter({
|
|
274
|
+
statDefinitions,
|
|
275
|
+
loadEventsOverWindow,
|
|
276
|
+
templateReplaceMany,
|
|
277
|
+
analyticsHtmlPath,
|
|
278
|
+
enableLiveReload,
|
|
279
|
+
environment,
|
|
280
|
+
}) {
|
|
281
|
+
console.log("statManifest", statDefinitions);
|
|
282
|
+
const router = express.Router();
|
|
283
|
+
|
|
284
|
+
router.get(`/data/:last/:date?`, async (request, response) => {
|
|
285
|
+
console.log(chalk.cyan("[API] GET /analytics/data/:last/:date?"));
|
|
286
|
+
await handleAnalyticsDataRequest({
|
|
287
|
+
request,
|
|
288
|
+
response,
|
|
289
|
+
statDefinitions,
|
|
290
|
+
loadEventsOverWindow,
|
|
291
|
+
nowAsPstDate,
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
router.get(`/layout`, async (req, res) => {
|
|
296
|
+
res.json(statDefinitions.ui || {});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
router.get(`/`, async (request, response) => {
|
|
300
|
+
console.log(chalk.cyan("[Render] views/analytics.html loading"));
|
|
301
|
+
console.log(path.join(__dirname, "..", "dist", "analytics.html"));
|
|
302
|
+
const fileContents = await readFile(
|
|
303
|
+
path.join(__dirname, "..", "dist", "analytics.html")
|
|
304
|
+
);
|
|
305
|
+
const analyticsHtmlRaw = fileContents.toString("utf-8");
|
|
306
|
+
console.log("analyticsHtmlRaw", analyticsHtmlRaw);
|
|
307
|
+
const yesterday = nowAsPstDate();
|
|
308
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
309
|
+
const analyticsHtml = templateReplaceMany(analyticsHtmlRaw, {
|
|
310
|
+
date: yesterday.toISOString().split("T")[0],
|
|
311
|
+
devHeadExtras:
|
|
312
|
+
environment === "development" && enableLiveReload
|
|
313
|
+
? '<script src="/liveReloading.js"></script>'
|
|
314
|
+
: "",
|
|
315
|
+
});
|
|
316
|
+
console.log("analyticsHtml", analyticsHtml);
|
|
317
|
+
response.send(analyticsHtml);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return router;
|
|
321
|
+
}
|
package/server/server.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Express middleware for hearald event tracking
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { nowAsPstDate } from "../shared/utils.js";
|
|
4
|
+
import { writeEvent, closeEventFiles } from "./serverEvents.js";
|
|
5
|
+
import { createAnalyticsRouter } from "./analytics.js";
|
|
6
|
+
|
|
7
|
+
export async function logEvent(time, event) {
|
|
8
|
+
return writeEvent(time, event);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function shutDown() {
|
|
12
|
+
await closeEventFiles();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns an express middleware for hearald event tracking.
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {string} [options.url='/e'] - The URL to listen for events.
|
|
19
|
+
* @param {function} [options.parseBody] - Custom request body parser (req => {e, u, p}).
|
|
20
|
+
* @returns {function} Express middleware
|
|
21
|
+
*/
|
|
22
|
+
export function createHearaldMiddleware({ url = "/e", parseBody } = {}) {
|
|
23
|
+
const router = express.Router();
|
|
24
|
+
router.post(url, async (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
let e, u, p;
|
|
27
|
+
if (parseBody) {
|
|
28
|
+
({ e, u, p } = parseBody(req));
|
|
29
|
+
} else {
|
|
30
|
+
({ e = null, u = null, p = {} } = req.body || {});
|
|
31
|
+
if (e === "visit") {
|
|
32
|
+
p.ip = req.ip;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
await logEvent(nowAsPstDate(), { e, u, p });
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
console.error("error writing event to event log", err);
|
|
39
|
+
}
|
|
40
|
+
res.status(204).send();
|
|
41
|
+
});
|
|
42
|
+
return router;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createHearaldAnalyticsRouter(options = {}) {
|
|
46
|
+
// Pass statManifest to the analytics router so /analytics/layout.json is available
|
|
47
|
+
return createAnalyticsRouter({ ...options });
|
|
48
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFile, open as openFile, mkdir } from "fs/promises";
|
|
2
|
+
|
|
3
|
+
import { iso } from "../shared/utils.js";
|
|
4
|
+
|
|
5
|
+
const eventFilePromises = new Map();
|
|
6
|
+
const lastWriteTimeouts = new Map();
|
|
7
|
+
|
|
8
|
+
export const openEventFile = async (fileName) => {
|
|
9
|
+
await mkdir("events", { recursive: true });
|
|
10
|
+
return openFile(`events/${fileName}.log`, "a");
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const writeEvent = async (time, event, isoFn = iso) => {
|
|
14
|
+
let fileName;
|
|
15
|
+
|
|
16
|
+
const dateString = isoFn(time);
|
|
17
|
+
|
|
18
|
+
if (event.e === "visit") {
|
|
19
|
+
fileName = `${dateString}-visits`;
|
|
20
|
+
} else if (event.e === "error") {
|
|
21
|
+
fileName = `${dateString}-errors`;
|
|
22
|
+
} else {
|
|
23
|
+
fileName = dateString;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!eventFilePromises.has(fileName)) {
|
|
27
|
+
eventFilePromises.set(fileName, openEventFile(fileName));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (lastWriteTimeouts.has(fileName)) {
|
|
31
|
+
clearTimeout(lastWriteTimeouts.get(fileName));
|
|
32
|
+
}
|
|
33
|
+
lastWriteTimeouts.set(
|
|
34
|
+
fileName,
|
|
35
|
+
setTimeout(() => closeEventFile(fileName), 60 * 1000)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const eventFile = await eventFilePromises.get(fileName);
|
|
39
|
+
return eventFile.write(`${JSON.stringify({ t: time, ...event })}\n`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const closeEventFile = async (fileName) => {
|
|
43
|
+
const eventFilePromise = eventFilePromises.get(fileName);
|
|
44
|
+
eventFilePromises.delete(fileName);
|
|
45
|
+
|
|
46
|
+
const timeout = lastWriteTimeouts.get(fileName);
|
|
47
|
+
lastWriteTimeouts.delete(fileName);
|
|
48
|
+
if (timeout) {
|
|
49
|
+
clearTimeout(timeout);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (eventFilePromise) {
|
|
53
|
+
return (await eventFilePromise).close();
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const closeEventFiles = async () => {
|
|
59
|
+
return Promise.all(
|
|
60
|
+
Array.from(eventFilePromises.keys()).map(async (fileName) =>
|
|
61
|
+
closeEventFile(fileName)
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const readEventsFromFile = async (fileName) => {
|
|
67
|
+
try {
|
|
68
|
+
return (await readFile(`events/${fileName}.log`))
|
|
69
|
+
.toString("utf8")
|
|
70
|
+
.split("\n")
|
|
71
|
+
.filter((r) => r.trim())
|
|
72
|
+
.map((row) => {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(row);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// skip malformed lines
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (e.message && e.message.indexOf("ENOENT") !== -1) {
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.warn(`file not found: events/${fileName}.log`);
|
|
85
|
+
} else {
|
|
86
|
+
// eslint-disable-next-line no-console
|
|
87
|
+
console.error(e);
|
|
88
|
+
}
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const loadEventsOverWindow = async (d, n, fileSuffix, isoFn = iso) => {
|
|
94
|
+
let events = [];
|
|
95
|
+
for (let i = 0; i < n; i++) {
|
|
96
|
+
const newDate = new Date(d);
|
|
97
|
+
newDate.setDate(d.getDate() - i);
|
|
98
|
+
let fileName = isoFn(newDate);
|
|
99
|
+
if (fileSuffix) {
|
|
100
|
+
fileName += `-${fileSuffix}`;
|
|
101
|
+
}
|
|
102
|
+
events = [...events, ...(await readEventsFromFile(fileName))];
|
|
103
|
+
}
|
|
104
|
+
return events;
|
|
105
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// hearald/src/serverTrackError.js
|
|
2
|
+
// Server-side utility to track errors
|
|
3
|
+
|
|
4
|
+
export async function trackServerError({
|
|
5
|
+
error,
|
|
6
|
+
logEvent,
|
|
7
|
+
userId = null,
|
|
8
|
+
extraParams = {},
|
|
9
|
+
nowFn = () => new Date(),
|
|
10
|
+
}) {
|
|
11
|
+
let message = "";
|
|
12
|
+
let stack = "";
|
|
13
|
+
if (error instanceof Error) {
|
|
14
|
+
message = error.message;
|
|
15
|
+
stack = error.stack || "";
|
|
16
|
+
} else if (typeof error === "string") {
|
|
17
|
+
message = error;
|
|
18
|
+
} else if (error && typeof error === "object") {
|
|
19
|
+
message = error.message || JSON.stringify(error);
|
|
20
|
+
stack = error.stack || "";
|
|
21
|
+
}
|
|
22
|
+
const params = {
|
|
23
|
+
message,
|
|
24
|
+
stack,
|
|
25
|
+
...extraParams,
|
|
26
|
+
};
|
|
27
|
+
await logEvent(nowFn(), { e: "error", u: userId, p: params });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Express error-handling middleware
|
|
31
|
+
export function errorTrackingMiddleware({
|
|
32
|
+
logEvent,
|
|
33
|
+
nowFn = () => new Date(),
|
|
34
|
+
userIdExtractor = (req) => req.user?.id,
|
|
35
|
+
}) {
|
|
36
|
+
// eslint-disable-next-line no-unused-vars
|
|
37
|
+
return function (err, req, res, next) {
|
|
38
|
+
trackServerError({
|
|
39
|
+
error: err,
|
|
40
|
+
logEvent,
|
|
41
|
+
userId: userIdExtractor(req),
|
|
42
|
+
extraParams: { url: req.originalUrl, method: req.method, ip: req.ip },
|
|
43
|
+
nowFn,
|
|
44
|
+
});
|
|
45
|
+
next(err);
|
|
46
|
+
};
|
|
47
|
+
}
|
package/shared/events.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Usage:
|
|
2
|
+
// 1. Register custom events with a schema
|
|
3
|
+
// 2. Emit custom events by name
|
|
4
|
+
|
|
5
|
+
import { EventBus } from "bussin-js";
|
|
6
|
+
|
|
7
|
+
// Shared event bus for all tracking events
|
|
8
|
+
export const eventBus = new EventBus([]);
|
|
9
|
+
|
|
10
|
+
const registeredEvents = {};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Registers a custom event type and its schema.
|
|
14
|
+
* @param {string} eventName - The name of the event.
|
|
15
|
+
* @param {object} schema - The schema definition for the event payload.
|
|
16
|
+
*/
|
|
17
|
+
export function registerEvent(eventName, schema) {
|
|
18
|
+
// Prevent duplicate registration
|
|
19
|
+
if (registeredEvents[eventName]) return;
|
|
20
|
+
registeredEvents[eventName] = schema;
|
|
21
|
+
// Add the event type to the tracking event bus if not already present
|
|
22
|
+
if (!eventBus.hasType(eventName)) {
|
|
23
|
+
eventBus.addType(eventName);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Emits a custom event if registered and payload matches schema.
|
|
29
|
+
* @param {string} eventName - The name of the event to emit.
|
|
30
|
+
* @param {object} payload - The event payload.
|
|
31
|
+
* @returns {boolean} - True if emitted, false if not registered or invalid payload.
|
|
32
|
+
*/
|
|
33
|
+
export function emitEvent(eventName, payload) {
|
|
34
|
+
const schema = registeredEvents[eventName];
|
|
35
|
+
if (!schema) return false;
|
|
36
|
+
// Simple schema validation: check required keys and types
|
|
37
|
+
for (const key in schema) {
|
|
38
|
+
if (!(key in payload)) return false;
|
|
39
|
+
if (typeof payload[key] !== schema[key]) return false;
|
|
40
|
+
}
|
|
41
|
+
// Emit on the shared tracking event bus
|
|
42
|
+
eventBus.emit(eventName, payload);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
package/shared/utils.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const pacificTimeFormatter = new Intl.DateTimeFormat("en-US", {
|
|
2
|
+
weekday: "short",
|
|
3
|
+
year: "numeric",
|
|
4
|
+
month: "numeric",
|
|
5
|
+
day: "numeric",
|
|
6
|
+
timeZone: "America/Los_Angeles",
|
|
7
|
+
timeZoneName: "short",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const pacificTimeOffsetHours = (date) =>
|
|
11
|
+
pacificTimeFormatter.format(date || new Date()).endsWith("PDT") ? -7 : -8;
|
|
12
|
+
|
|
13
|
+
export const dateStringToPstDate = (dateString) => {
|
|
14
|
+
const rawDate = dateString ? new Date(dateString) : new Date();
|
|
15
|
+
const offset = pacificTimeOffsetHours(rawDate);
|
|
16
|
+
const pstDate = new Date();
|
|
17
|
+
pstDate.setUTCFullYear(
|
|
18
|
+
rawDate.getUTCFullYear(),
|
|
19
|
+
rawDate.getUTCMonth(),
|
|
20
|
+
rawDate.getUTCDate()
|
|
21
|
+
);
|
|
22
|
+
pstDate.setUTCHours(-1 * offset, 0, 0, 0);
|
|
23
|
+
return pstDate;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const nowAsPstDate = () => dateStringToPstDate();
|
|
27
|
+
|
|
28
|
+
export function omitProperties(object, ...keys) {
|
|
29
|
+
let rest = object;
|
|
30
|
+
keys.forEach((key) => {
|
|
31
|
+
// eslint-disable-next-line no-unused-vars
|
|
32
|
+
const { [key]: _omit, ...other } = rest;
|
|
33
|
+
rest = other;
|
|
34
|
+
});
|
|
35
|
+
return rest;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function iso(time) {
|
|
39
|
+
return [
|
|
40
|
+
time.getUTCFullYear(),
|
|
41
|
+
(time.getUTCMonth() + 1).toString().padStart(2, "0"),
|
|
42
|
+
time.getUTCDate().toString().padStart(2, "0"),
|
|
43
|
+
].join("-");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function defaultEndDate() {
|
|
47
|
+
const yesterday = nowAsPstDate();
|
|
48
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
49
|
+
yesterday.setHours(0, 0, 0, 0);
|
|
50
|
+
return yesterday;
|
|
51
|
+
}
|