hearald 0.0.4 → 0.1.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/CHANGELOG.md +133 -0
- package/README.md +120 -46
- package/dist/analytics.html +4 -4
- package/dist/index.css +2 -2
- package/dist/index.js +2 -2
- package/package.json +19 -5
- package/src/client.js +27 -7
- package/src/index.js +57 -11
- package/src/public/css/analytics.css +2 -2
- package/src/public/js/App.js +2 -2
- package/src/server/analytics.js +225 -72
- package/src/server/asyncHandler.js +6 -0
- package/src/server/auth.js +89 -0
- package/src/server/configuration.js +53 -0
- package/src/server/logger.js +14 -14
- package/src/server/server.js +99 -11
- package/src/server/serverEvents.js +154 -43
- package/src/server/validate.js +56 -0
- package/src/shared/utils.js +32 -16
- package/.eslintrc +0 -14
- package/TODO.md +0 -34
- package/rollup.config.js +0 -59
- package/src/server/serverTrackError.js +0 -47
package/src/server/server.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// Express middleware for hearald event tracking
|
|
2
2
|
import express from "express";
|
|
3
|
-
import log from "./logger.js";
|
|
4
|
-
import { nowAsPstDate } from "../shared/utils.js";
|
|
5
|
-
import { writeEvent, closeEventFiles } from "./serverEvents.js";
|
|
6
|
-
import { createAnalyticsRouter } from "./analytics.js";
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
import hearaldConfiguration from "./configuration.js";
|
|
5
|
+
import log from "./logger.js";
|
|
6
|
+
import {
|
|
7
|
+
writeEvent,
|
|
8
|
+
closeEventFiles,
|
|
9
|
+
VISIT_EVENT,
|
|
10
|
+
ERROR_EVENT,
|
|
11
|
+
} from "./serverEvents.js";
|
|
12
|
+
import { validateEvent } from "./validate.js";
|
|
11
13
|
|
|
12
14
|
export async function shutDown() {
|
|
13
15
|
await closeEventFiles();
|
|
@@ -18,9 +20,12 @@ export async function shutDown() {
|
|
|
18
20
|
* @param {Object} options
|
|
19
21
|
* @param {string} [options.url='/e'] - The URL to listen for events.
|
|
20
22
|
* @param {function} [options.parseBody] - Custom request body parser (req => {e, u, p}).
|
|
23
|
+
* @param {Object} [options.schema] - Allowed events and their parameters (see validate.js). When
|
|
24
|
+
* given, events not in it, and parameters not listed, are dropped. Strongly recommended for any
|
|
25
|
+
* endpoint that is open to the internet.
|
|
21
26
|
* @returns {function} Express middleware
|
|
22
27
|
*/
|
|
23
|
-
export function eventEndpointMiddleware({ url = "/e", parseBody } = {}) {
|
|
28
|
+
export function eventEndpointMiddleware({ url = "/e", parseBody, schema } = {}) {
|
|
24
29
|
const router = express.Router();
|
|
25
30
|
router.post(url, async (req, res) => {
|
|
26
31
|
try {
|
|
@@ -29,11 +34,25 @@ export function eventEndpointMiddleware({ url = "/e", parseBody } = {}) {
|
|
|
29
34
|
({ e, u, p } = parseBody(req));
|
|
30
35
|
} else {
|
|
31
36
|
({ e = null, u = null, p = {} } = req.body || {});
|
|
32
|
-
if (
|
|
33
|
-
|
|
37
|
+
if (p === null || typeof p !== "object" || Array.isArray(p)) p = {};
|
|
38
|
+
if (e === VISIT_EVENT) {
|
|
39
|
+
p = { ...p, ip: req.ip };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (schema) {
|
|
43
|
+
const clean = validateEvent(schema, { e, u, p });
|
|
44
|
+
if (!clean) {
|
|
45
|
+
// Answer the same as for a good event, so the endpoint reveals nothing about the schema.
|
|
46
|
+
res.status(204).send();
|
|
47
|
+
return;
|
|
34
48
|
}
|
|
49
|
+
({ e, u, p } = clean);
|
|
35
50
|
}
|
|
36
|
-
|
|
51
|
+
if (typeof e !== "string" || !e) {
|
|
52
|
+
res.status(204).send();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
await writeEvent(hearaldConfiguration.nowFn(), { e, u, p });
|
|
37
56
|
} catch (err) {
|
|
38
57
|
// eslint-disable-next-line no-console
|
|
39
58
|
log.error("error writing event to event log", err);
|
|
@@ -42,3 +61,72 @@ export function eventEndpointMiddleware({ url = "/e", parseBody } = {}) {
|
|
|
42
61
|
});
|
|
43
62
|
return router;
|
|
44
63
|
}
|
|
64
|
+
|
|
65
|
+
export async function trackServerEvent({
|
|
66
|
+
event,
|
|
67
|
+
time = null,
|
|
68
|
+
userId,
|
|
69
|
+
params = {},
|
|
70
|
+
}) {
|
|
71
|
+
await writeEvent(time || hearaldConfiguration.nowFn(), {
|
|
72
|
+
e: event,
|
|
73
|
+
u: userId,
|
|
74
|
+
p: params,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// JSON.stringify throws on circular structures (and on BigInt); an error handler must not.
|
|
79
|
+
function safeStringify(value) {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.stringify(value);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return String(value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function trackServerError({
|
|
88
|
+
error,
|
|
89
|
+
userId = null,
|
|
90
|
+
extraParams = {},
|
|
91
|
+
}) {
|
|
92
|
+
let message = "";
|
|
93
|
+
let stack = "";
|
|
94
|
+
if (error instanceof Error) {
|
|
95
|
+
message = error.message;
|
|
96
|
+
stack = error.stack || "";
|
|
97
|
+
} else if (typeof error === "string") {
|
|
98
|
+
message = error;
|
|
99
|
+
} else if (error && typeof error === "object") {
|
|
100
|
+
message = error.message || safeStringify(error);
|
|
101
|
+
stack = error.stack || "";
|
|
102
|
+
}
|
|
103
|
+
const params = {
|
|
104
|
+
message,
|
|
105
|
+
stack,
|
|
106
|
+
...extraParams,
|
|
107
|
+
};
|
|
108
|
+
await writeEvent(hearaldConfiguration.nowFn(), {
|
|
109
|
+
e: ERROR_EVENT,
|
|
110
|
+
u: userId,
|
|
111
|
+
p: params,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Express error-handling middleware
|
|
116
|
+
export function errorTrackingMiddleware({}) {
|
|
117
|
+
// eslint-disable-next-line no-unused-vars
|
|
118
|
+
return function (err, req, res, next) {
|
|
119
|
+
// Recording the error must never stop it reaching the app's own handler, nor crash the process
|
|
120
|
+
// (a failed write is a rejected promise nobody awaits).
|
|
121
|
+
try {
|
|
122
|
+
trackServerError({
|
|
123
|
+
error: err,
|
|
124
|
+
userId: hearaldConfiguration.getUserId(req),
|
|
125
|
+
extraParams: { url: req.originalUrl, method: req.method, ip: req.ip },
|
|
126
|
+
}).catch((e) => log.error("could not record the error", e));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
log.error("could not record the error", e);
|
|
129
|
+
}
|
|
130
|
+
next(err);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -1,72 +1,122 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
readFile,
|
|
3
|
+
readdir,
|
|
4
|
+
unlink,
|
|
5
|
+
stat,
|
|
6
|
+
open as openFile,
|
|
7
|
+
mkdir,
|
|
8
|
+
} from "fs/promises";
|
|
9
|
+
import { join } from "path";
|
|
2
10
|
|
|
11
|
+
import hearaldConfiguration from "./configuration.js";
|
|
3
12
|
import log from "./logger.js";
|
|
4
|
-
|
|
13
|
+
|
|
14
|
+
export const VISIT_EVENT = "visit";
|
|
15
|
+
export const ERROR_EVENT = "error";
|
|
5
16
|
|
|
6
17
|
const eventFilePromises = new Map();
|
|
7
18
|
const lastWriteTimeouts = new Map();
|
|
8
19
|
|
|
20
|
+
// Events live in hearaldConfiguration.eventsDir (default "events", relative to the working directory).
|
|
21
|
+
export const eventFilePath = (fileName) =>
|
|
22
|
+
join(hearaldConfiguration.eventsDir, `${fileName}.log`);
|
|
23
|
+
|
|
24
|
+
// A crash can leave the last line unfinished. Appending straight after it would glue the next event
|
|
25
|
+
// onto the broken line and lose both, so start a fresh line first.
|
|
26
|
+
const endWithNewline = async (file) => {
|
|
27
|
+
const { size } = await file.stat();
|
|
28
|
+
if (size === 0) return;
|
|
29
|
+
const last = Buffer.alloc(1);
|
|
30
|
+
await file.read(last, 0, 1, size - 1);
|
|
31
|
+
if (last[0] !== 0x0a) await file.write("\n");
|
|
32
|
+
};
|
|
33
|
+
|
|
9
34
|
export const openEventFile = async (fileName) => {
|
|
10
|
-
await mkdir(
|
|
11
|
-
|
|
35
|
+
await mkdir(hearaldConfiguration.eventsDir, { recursive: true });
|
|
36
|
+
// "a+" appends like "a" but lets us look at the end of the file first
|
|
37
|
+
const file = await openFile(eventFilePath(fileName), "a+");
|
|
38
|
+
try {
|
|
39
|
+
await endWithNewline(file);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
await file.close();
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
return file;
|
|
12
45
|
};
|
|
13
46
|
|
|
14
|
-
export const writeEvent = async (time, event
|
|
47
|
+
export const writeEvent = async (time, event) => {
|
|
15
48
|
let fileName;
|
|
16
49
|
|
|
17
|
-
const dateString = isoFn(time);
|
|
50
|
+
const dateString = hearaldConfiguration.isoFn(time);
|
|
18
51
|
|
|
19
|
-
if (event.e ===
|
|
52
|
+
if (event.e === VISIT_EVENT) {
|
|
20
53
|
fileName = `${dateString}-visits`;
|
|
21
|
-
} else if (event.e ===
|
|
54
|
+
} else if (event.e === ERROR_EVENT) {
|
|
22
55
|
fileName = `${dateString}-errors`;
|
|
23
56
|
} else {
|
|
24
57
|
fileName = dateString;
|
|
25
58
|
}
|
|
26
59
|
|
|
27
|
-
|
|
28
|
-
|
|
60
|
+
const key = eventFilePath(fileName);
|
|
61
|
+
if (!eventFilePromises.has(key)) {
|
|
62
|
+
const opening = openEventFile(fileName);
|
|
63
|
+
eventFilePromises.set(key, opening);
|
|
64
|
+
// A failed open must not stay cached: every later write would fail with it, and closing it
|
|
65
|
+
// from the idle timer would be an unhandled rejection that kills the process.
|
|
66
|
+
opening.catch(() => {
|
|
67
|
+
if (eventFilePromises.get(key) === opening) {
|
|
68
|
+
eventFilePromises.delete(key);
|
|
69
|
+
clearIdleTimer(key);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
29
72
|
}
|
|
30
73
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
setTimeout(() => closeEventFile(fileName), 60 * 1000)
|
|
74
|
+
clearIdleTimer(key);
|
|
75
|
+
// Close by the key captured now: the events directory may have changed by the time this fires.
|
|
76
|
+
const timer = setTimeout(
|
|
77
|
+
() => closeByKey(key),
|
|
78
|
+
hearaldConfiguration.idleCloseMs
|
|
37
79
|
);
|
|
80
|
+
timer.unref(); // an idle file handle must not keep a script alive
|
|
81
|
+
lastWriteTimeouts.set(key, timer);
|
|
38
82
|
|
|
39
|
-
const eventFile = await eventFilePromises.get(
|
|
83
|
+
const eventFile = await eventFilePromises.get(key);
|
|
40
84
|
return eventFile.write(`${JSON.stringify({ t: time, ...event })}\n`);
|
|
41
85
|
};
|
|
42
86
|
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
87
|
+
function clearIdleTimer(key) {
|
|
88
|
+
const timeout = lastWriteTimeouts.get(key);
|
|
89
|
+
lastWriteTimeouts.delete(key);
|
|
90
|
+
if (timeout) clearTimeout(timeout);
|
|
91
|
+
}
|
|
46
92
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
93
|
+
// Never rejects: it runs from timers and shutdown, where nobody could catch it. A failed open was
|
|
94
|
+
// already reported to the writer that asked for it.
|
|
95
|
+
async function closeByKey(key) {
|
|
96
|
+
const eventFilePromise = eventFilePromises.get(key);
|
|
97
|
+
eventFilePromises.delete(key);
|
|
98
|
+
clearIdleTimer(key);
|
|
99
|
+
if (!eventFilePromise) return;
|
|
100
|
+
try {
|
|
101
|
+
await (await eventFilePromise).close();
|
|
102
|
+
} catch (e) {
|
|
103
|
+
log.debug(`could not close ${key}: ${e.message}`);
|
|
51
104
|
}
|
|
105
|
+
}
|
|
52
106
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
107
|
+
// How many event files are open or opening (for tests and diagnostics).
|
|
108
|
+
export const openEventFileCount = () => eventFilePromises.size;
|
|
109
|
+
|
|
110
|
+
export const closeEventFile = (fileName) =>
|
|
111
|
+
closeByKey(eventFilePath(fileName));
|
|
58
112
|
|
|
59
113
|
export const closeEventFiles = async () => {
|
|
60
|
-
|
|
61
|
-
Array.from(eventFilePromises.keys()).map(async (fileName) =>
|
|
62
|
-
closeEventFile(fileName)
|
|
63
|
-
)
|
|
64
|
-
);
|
|
114
|
+
await Promise.all(Array.from(eventFilePromises.keys()).map(closeByKey));
|
|
65
115
|
};
|
|
66
116
|
|
|
67
117
|
export const readEventsFromFile = async (fileName) => {
|
|
68
118
|
try {
|
|
69
|
-
return (await readFile(
|
|
119
|
+
return (await readFile(eventFilePath(fileName)))
|
|
70
120
|
.toString("utf8")
|
|
71
121
|
.split("\n")
|
|
72
122
|
.filter((r) => r.trim())
|
|
@@ -80,9 +130,9 @@ export const readEventsFromFile = async (fileName) => {
|
|
|
80
130
|
})
|
|
81
131
|
.filter(Boolean);
|
|
82
132
|
} catch (e) {
|
|
83
|
-
if (e.
|
|
84
|
-
//
|
|
85
|
-
log.
|
|
133
|
+
if (e.code === "ENOENT") {
|
|
134
|
+
// a day with no events has no file; that is normal
|
|
135
|
+
log.debug(`no events file: ${eventFilePath(fileName)}`);
|
|
86
136
|
} else {
|
|
87
137
|
// eslint-disable-next-line no-console
|
|
88
138
|
log.error(e);
|
|
@@ -91,16 +141,77 @@ export const readEventsFromFile = async (fileName) => {
|
|
|
91
141
|
}
|
|
92
142
|
};
|
|
93
143
|
|
|
94
|
-
|
|
95
|
-
|
|
144
|
+
// The file names of the n days ending on d (UTC fields, like `iso`).
|
|
145
|
+
export const windowFileNames = (d, n, fileSuffix) => {
|
|
146
|
+
const names = [];
|
|
96
147
|
for (let i = 0; i < n; i++) {
|
|
148
|
+
// `iso` reads the UTC fields, so step back in UTC too (local-time maths skips or repeats a day
|
|
149
|
+
// around daylight saving changes on servers not set to UTC)
|
|
97
150
|
const newDate = new Date(d);
|
|
98
|
-
newDate.
|
|
99
|
-
let fileName = isoFn(newDate);
|
|
151
|
+
newDate.setUTCDate(d.getUTCDate() - i);
|
|
152
|
+
let fileName = hearaldConfiguration.isoFn(newDate);
|
|
100
153
|
if (fileSuffix) {
|
|
101
154
|
fileName += `-${fileSuffix}`;
|
|
102
155
|
}
|
|
103
|
-
|
|
156
|
+
names.push(fileName);
|
|
157
|
+
}
|
|
158
|
+
return names;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Total size in bytes of the event files for a window, so a window too big for memory can be refused
|
|
162
|
+
// before anything is read.
|
|
163
|
+
export const windowBytes = async (d, n, fileSuffix) => {
|
|
164
|
+
let total = 0;
|
|
165
|
+
for (const fileName of windowFileNames(d, n, fileSuffix)) {
|
|
166
|
+
try {
|
|
167
|
+
total += (await stat(eventFilePath(fileName))).size;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (e.code !== "ENOENT") throw e;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return total;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const loadEventsOverWindow = async (d, n, fileSuffix) => {
|
|
176
|
+
const events = [];
|
|
177
|
+
for (const fileName of windowFileNames(d, n, fileSuffix)) {
|
|
178
|
+
// push, not spreading into a new array each day, which is quadratic
|
|
179
|
+
for (const event of await readEventsFromFile(fileName)) events.push(event);
|
|
104
180
|
}
|
|
105
181
|
return events;
|
|
106
182
|
};
|
|
183
|
+
|
|
184
|
+
// Deletes event logs and cached summaries older than `days` days (by the date in the file name; a
|
|
185
|
+
// file dated exactly `days` days ago is kept). Counts days with the configured clock. Returns the number of files removed. Run it nightly to honour a retention period.
|
|
186
|
+
export const purgeOldEvents = async ({
|
|
187
|
+
days,
|
|
188
|
+
now = hearaldConfiguration.nowFn(),
|
|
189
|
+
} = {}) => {
|
|
190
|
+
if (!Number.isInteger(days) || days < 1) {
|
|
191
|
+
throw new Error("purgeOldEvents needs a whole number of days, 1 or more");
|
|
192
|
+
}
|
|
193
|
+
// UTC fields, like `isoFn`; local-time maths could be off by a day around daylight saving changes
|
|
194
|
+
const cutoff = new Date(now);
|
|
195
|
+
cutoff.setUTCDate(cutoff.getUTCDate() - days);
|
|
196
|
+
const cutoffIso = hearaldConfiguration.isoFn(cutoff);
|
|
197
|
+
|
|
198
|
+
let names;
|
|
199
|
+
try {
|
|
200
|
+
names = await readdir(hearaldConfiguration.eventsDir);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (e.code === "ENOENT") return 0;
|
|
203
|
+
throw e;
|
|
204
|
+
}
|
|
205
|
+
let removed = 0;
|
|
206
|
+
for (const name of names) {
|
|
207
|
+
const match =
|
|
208
|
+
/^(\d{4}-\d{2}-\d{2})(?:-[a-z]+)?\.log$/.exec(name) ||
|
|
209
|
+
/^summary:(\d{4}-\d{2}-\d{2}):\d+(?::[0-9a-f]+)?\.json$/.exec(name);
|
|
210
|
+
if (match && match[1] < cutoffIso) {
|
|
211
|
+
await closeEventFile(name.replace(/\.log$/, ""));
|
|
212
|
+
await unlink(join(hearaldConfiguration.eventsDir, name));
|
|
213
|
+
removed += 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return removed;
|
|
217
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Validates events arriving on the public event endpoint against a schema, so the endpoint cannot
|
|
2
|
+
// be used to store arbitrary text or unbounded data.
|
|
3
|
+
//
|
|
4
|
+
// schema = {
|
|
5
|
+
// completion_added: {
|
|
6
|
+
// platform: { type: "string", enum: ["web", "ios"], required: true },
|
|
7
|
+
// goal_type: { type: "string", enum: ["binary", "counter", "monthly"] },
|
|
8
|
+
// count: { type: "number", integer: true, min: 0, max: 1000 },
|
|
9
|
+
// },
|
|
10
|
+
// };
|
|
11
|
+
//
|
|
12
|
+
// Events not in the schema, and parameters not listed for their event, are dropped. An event with a
|
|
13
|
+
// missing required parameter or a parameter of the wrong type is rejected whole.
|
|
14
|
+
|
|
15
|
+
const MAX_USER_ID_LENGTH = 64;
|
|
16
|
+
const DEFAULT_MAX_STRING = 64;
|
|
17
|
+
|
|
18
|
+
function checkParam(spec, value) {
|
|
19
|
+
switch (spec.type) {
|
|
20
|
+
case "string":
|
|
21
|
+
if (typeof value !== "string") return { ok: false };
|
|
22
|
+
if (value.length > (spec.maxLength ?? DEFAULT_MAX_STRING)) return { ok: false };
|
|
23
|
+
if (spec.enum && !spec.enum.includes(value)) return { ok: false };
|
|
24
|
+
return { ok: true, value };
|
|
25
|
+
case "number":
|
|
26
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return { ok: false };
|
|
27
|
+
if (spec.integer && !Number.isInteger(value)) return { ok: false };
|
|
28
|
+
if (spec.min !== undefined && value < spec.min) return { ok: false };
|
|
29
|
+
if (spec.max !== undefined && value > spec.max) return { ok: false };
|
|
30
|
+
return { ok: true, value };
|
|
31
|
+
case "boolean":
|
|
32
|
+
return typeof value === "boolean" ? { ok: true, value } : { ok: false };
|
|
33
|
+
default:
|
|
34
|
+
throw new Error(`unknown parameter type "${spec.type}"`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Returns the cleaned { e, u, p }, or null when the event should be dropped.
|
|
39
|
+
export function validateEvent(schema, { e, u, p } = {}) {
|
|
40
|
+
if (typeof e !== "string" || !Object.hasOwn(schema, e)) return null;
|
|
41
|
+
if (u !== null && u !== undefined) {
|
|
42
|
+
if (typeof u !== "string" || !u || u.length > MAX_USER_ID_LENGTH) return null;
|
|
43
|
+
}
|
|
44
|
+
const given = p && typeof p === "object" && !Array.isArray(p) ? p : {};
|
|
45
|
+
const clean = {};
|
|
46
|
+
for (const [name, spec] of Object.entries(schema[e])) {
|
|
47
|
+
if (!Object.hasOwn(given, name)) {
|
|
48
|
+
if (spec.required) return null;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const result = checkParam(spec, given[name]);
|
|
52
|
+
if (!result.ok) return null;
|
|
53
|
+
clean[name] = result.value;
|
|
54
|
+
}
|
|
55
|
+
return { e, u: u ?? null, p: clean };
|
|
56
|
+
}
|
package/src/shared/utils.js
CHANGED
|
@@ -10,21 +10,36 @@ export const pacificTimeFormatter = new Intl.DateTimeFormat("en-US", {
|
|
|
10
10
|
export const pacificTimeOffsetHours = (date) =>
|
|
11
11
|
pacificTimeFormatter.format(date || new Date()).endsWith("PDT") ? -7 : -8;
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
// A Date whose UTC fields hold the current Pacific wall-clock time, so `iso()` (which reads UTC
|
|
14
|
+
// fields) names the Pacific day. The instant it represents is not "now"; it is only for day buckets.
|
|
15
|
+
export const nowAsPstDate = (now = new Date()) => {
|
|
16
|
+
const parts = Object.fromEntries(
|
|
17
|
+
new Intl.DateTimeFormat("en-US", {
|
|
18
|
+
timeZone: "America/Los_Angeles",
|
|
19
|
+
hourCycle: "h23",
|
|
20
|
+
year: "numeric",
|
|
21
|
+
month: "numeric",
|
|
22
|
+
day: "numeric",
|
|
23
|
+
hour: "numeric",
|
|
24
|
+
minute: "numeric",
|
|
25
|
+
second: "numeric",
|
|
26
|
+
})
|
|
27
|
+
.formatToParts(now)
|
|
28
|
+
.map((p) => [p.type, p.value])
|
|
29
|
+
);
|
|
30
|
+
return new Date(
|
|
31
|
+
Date.UTC(
|
|
32
|
+
Number(parts.year),
|
|
33
|
+
Number(parts.month) - 1,
|
|
34
|
+
Number(parts.day),
|
|
35
|
+
Number(parts.hour),
|
|
36
|
+
Number(parts.minute),
|
|
37
|
+
Number(parts.second),
|
|
38
|
+
now.getMilliseconds()
|
|
39
|
+
)
|
|
21
40
|
);
|
|
22
|
-
pstDate.setUTCHours(-1 * offset, 0, 0, 0);
|
|
23
|
-
return pstDate;
|
|
24
41
|
};
|
|
25
42
|
|
|
26
|
-
export const nowAsPstDate = () => dateStringToPstDate();
|
|
27
|
-
|
|
28
43
|
export function omitProperties(object, ...keys) {
|
|
29
44
|
let rest = object;
|
|
30
45
|
keys.forEach((key) => {
|
|
@@ -43,9 +58,10 @@ export function iso(time) {
|
|
|
43
58
|
].join("-");
|
|
44
59
|
}
|
|
45
60
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
yesterday
|
|
49
|
-
yesterday.
|
|
61
|
+
// Midnight (UTC fields) at the start of yesterday, in the same day buckets as nowAsPstDate.
|
|
62
|
+
export function defaultEndDate(now = nowAsPstDate()) {
|
|
63
|
+
const yesterday = new Date(now);
|
|
64
|
+
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
|
65
|
+
yesterday.setUTCHours(0, 0, 0, 0);
|
|
50
66
|
return yesterday;
|
|
51
67
|
}
|
package/.eslintrc
DELETED
package/TODO.md
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# hearald TODO
|
|
2
|
-
|
|
3
|
-
## Status (August 2025)
|
|
4
|
-
|
|
5
|
-
- Event bus/evented logic, event file helpers, emit logic, admin stats, analytics helpers, and error tracking utilities have all been extracted and modularized.
|
|
6
|
-
- hearald now uses client/server/shared subfolders and has a working .eslintrc for Intl.
|
|
7
|
-
- Lint passes and all functionality is verified.
|
|
8
|
-
- Migration of all helpers to client/server/shared is nearly complete.
|
|
9
|
-
|
|
10
|
-
## Next Steps for hearald
|
|
11
|
-
|
|
12
|
-
1. **Tracking Custom Events**
|
|
13
|
-
|
|
14
|
-
- Make the event schema/config easy to extend (document how to define custom events)
|
|
15
|
-
- Provide a clear API for emitting custom events (e.g., `trackEvent('my_event', {...})`)
|
|
16
|
-
|
|
17
|
-
2. **Generating Statistical Summary Views**
|
|
18
|
-
|
|
19
|
-
- Make stat definitions configurable (allow user to provide their own stat_definition.json)
|
|
20
|
-
- Expose a summary API (e.g., `generateStats(endDate, timeWindow)`)
|
|
21
|
-
- Document how to add new metrics and computed stats
|
|
22
|
-
|
|
23
|
-
3. **Admin UI to See Stats**
|
|
24
|
-
- Provide a basic, embeddable React/Preact/Vue component for admin stats (or a template)
|
|
25
|
-
- Document how to use the endpoint and client fetcher in any admin UI
|
|
26
|
-
- Optionally, provide a standalone admin page as part of the package
|
|
27
|
-
|
|
28
|
-
## General Improvements
|
|
29
|
-
|
|
30
|
-
- Write clear documentation and usage examples
|
|
31
|
-
- Add configuration options for stat definitions and event types
|
|
32
|
-
- Provide ready-to-use middleware and client helpers for common event types
|
|
33
|
-
- Package a basic admin UI or template
|
|
34
|
-
- Test in a clean, new project to validate ease of adoption
|
package/rollup.config.js
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import resolve from "@rollup/plugin-node-resolve";
|
|
4
|
-
import postcss from "rollup-plugin-postcss";
|
|
5
|
-
|
|
6
|
-
// Custom plugin to inline JS and CSS into HTML
|
|
7
|
-
function inlineAssetsPlugin({ htmlFile, outputFile }) {
|
|
8
|
-
return {
|
|
9
|
-
name: "inline-assets",
|
|
10
|
-
async writeBundle(options, bundle) {
|
|
11
|
-
const htmlPath = path.resolve(htmlFile);
|
|
12
|
-
let html = fs.readFileSync(htmlPath, "utf8");
|
|
13
|
-
|
|
14
|
-
// Find JS and CSS output from bundle
|
|
15
|
-
const jsBundle = Object.values(bundle).find(
|
|
16
|
-
(file) => file.type === "chunk" && file.isEntry
|
|
17
|
-
);
|
|
18
|
-
const cssBundle = Object.values(bundle).find(
|
|
19
|
-
(file) => file.type === "asset" && file.fileName.endsWith(".css")
|
|
20
|
-
);
|
|
21
|
-
|
|
22
|
-
const js = jsBundle ? jsBundle.code : "";
|
|
23
|
-
const css = cssBundle ? cssBundle.source : "";
|
|
24
|
-
|
|
25
|
-
// Replace <script src=...> and <link rel="stylesheet" ...> with inline tags
|
|
26
|
-
html = html.replace(
|
|
27
|
-
/<script\s+src="[^"]*"[^>]*><\/script>/,
|
|
28
|
-
`<script>${js}</script>`
|
|
29
|
-
);
|
|
30
|
-
// Replace any <link rel="stylesheet" ...> (with any attributes)
|
|
31
|
-
html = html.replace(
|
|
32
|
-
/<link\s+rel=["']stylesheet["'][^>]*>/gi,
|
|
33
|
-
`<style>${css}</style>`
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
// Write the inlined HTML
|
|
37
|
-
fs.writeFileSync(path.resolve(options.dir, outputFile), html);
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export default {
|
|
43
|
-
input: "src/public/js/index.js", // Use JS entry point
|
|
44
|
-
output: {
|
|
45
|
-
dir: "dist",
|
|
46
|
-
format: "iife",
|
|
47
|
-
},
|
|
48
|
-
plugins: [
|
|
49
|
-
resolve(),
|
|
50
|
-
postcss({
|
|
51
|
-
inject: false,
|
|
52
|
-
extract: true,
|
|
53
|
-
}),
|
|
54
|
-
inlineAssetsPlugin({
|
|
55
|
-
htmlFile: "src/public/analytics.html",
|
|
56
|
-
outputFile: "analytics.html",
|
|
57
|
-
}),
|
|
58
|
-
],
|
|
59
|
-
};
|
|
@@ -1,47 +0,0 @@
|
|
|
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
|
-
}
|