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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }