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.
@@ -1,17 +1,44 @@
1
1
  // Analytics router and helpers for hearald
2
2
  import chalk from "chalk";
3
3
  import path from "path";
4
+ import { createHash } from "crypto";
4
5
  import { fileURLToPath } from "url";
5
- import { readFile, writeFile } from "fs/promises";
6
+ import { readFile, writeFile, mkdir } from "fs/promises";
6
7
  import express from "express";
7
8
  import log from "./logger.js";
8
- import {
9
- omitProperties,
10
- iso,
11
- defaultEndDate,
12
- nowAsPstDate,
13
- } from "../shared/utils.js";
14
- import { loadEventsOverWindow } from "./serverEvents.js";
9
+ import hearaldConfiguration from "./configuration.js";
10
+ import { omitProperties, defaultEndDate, iso } from "../shared/utils.js";
11
+ import { loadEventsOverWindow, windowBytes } from "./serverEvents.js";
12
+ import { asyncHandler } from "./asyncHandler.js";
13
+
14
+ const MAX_TIME_WINDOW_DAYS = 366;
15
+
16
+ // Bump when the way numbers are computed or stored changes, so summaries cached by an older version
17
+ // are not served.
18
+ const CACHE_FORMAT_VERSION = 1;
19
+
20
+ // Thrown when a window's event files are bigger than the memory budget.
21
+ export class WindowTooLargeError extends Error {
22
+ constructor(bytes, limit) {
23
+ super(`window too large (${bytes} bytes of events, the limit is ${limit})`);
24
+ this.name = "WindowTooLargeError";
25
+ }
26
+ }
27
+
28
+ // Computing stats holds a whole window of events in memory, so run one computation at a time: with
29
+ // several at once, memory use multiplies.
30
+ let queue = Promise.resolve();
31
+ export function runExclusive(task) {
32
+ const result = queue.then(task);
33
+ queue = result.catch(() => {});
34
+ return result;
35
+ }
36
+
37
+ // A histogram value is a string, number or boolean. 0, false and "" are real values.
38
+ const histogramValue = (params, field) => {
39
+ const value = params ? params[field] : undefined;
40
+ return ["string", "number", "boolean"].includes(typeof value) ? value : null;
41
+ };
15
42
 
16
43
  export function generateRawMetrics(events, rawMetricDefinitions) {
17
44
  const metrics = {};
@@ -22,12 +49,14 @@ export function generateRawMetrics(events, rawMetricDefinitions) {
22
49
  } else if (rawMetricDefinition.aggregator === "set_of_users") {
23
50
  metrics[name] = new Set();
24
51
  } else if (rawMetricDefinition.aggregator === "histogram") {
25
- metrics[name] = {};
52
+ // no prototype, so a value named "constructor" or "__proto__" is just a key
53
+ metrics[name] = Object.create(null);
26
54
  }
27
55
  }
28
56
  );
29
57
 
30
- events.forEach(({ e, p, u }) => {
58
+ events.forEach(({ e, p: eventParams, u }) => {
59
+ const p = eventParams || {};
31
60
  Object.entries(rawMetricDefinitions).forEach(
32
61
  ([name, rawMetricDefinition]) => {
33
62
  if (rawMetricDefinition.filter) {
@@ -36,19 +65,26 @@ export function generateRawMetrics(events, rawMetricDefinitions) {
36
65
  );
37
66
  if (!match) return;
38
67
  }
68
+ // A set_of_users metric that names an event counts only users who sent that event.
69
+ if (
70
+ rawMetricDefinition.aggregator === "set_of_users" &&
71
+ rawMetricDefinition.event &&
72
+ rawMetricDefinition.event !== e
73
+ ) {
74
+ return;
75
+ }
39
76
  if (
40
77
  rawMetricDefinition.aggregator === "count" &&
41
78
  rawMetricDefinition.event === e
42
79
  ) {
43
80
  metrics[name] += 1;
44
81
  } else if (rawMetricDefinition.aggregator === "set_of_users") {
45
- metrics[name].add(u);
82
+ if (u !== null && u !== undefined) metrics[name].add(u);
46
83
  } else if (
47
84
  rawMetricDefinition.aggregator === "histogram" &&
48
85
  rawMetricDefinition.event === e
49
86
  ) {
50
- const field = rawMetricDefinition.field;
51
- const value = p && p[field] ? p[field] : null;
87
+ const value = histogramValue(p, rawMetricDefinition.field);
52
88
  if (value !== null) {
53
89
  metrics[name][value] = (metrics[name][value] || 0) + 1;
54
90
  }
@@ -92,38 +128,70 @@ export function generateComputedMetric(metrics, definition, timeWindow) {
92
128
  // Compute histogram for given event type and field
93
129
  const sourceEvent = definition.source;
94
130
  const field = definition.field;
95
- const histogram = {};
131
+ const histogram = Object.create(null);
96
132
  // Find all events of the given type
97
133
  (metrics._allEvents || []).forEach((ev) => {
98
- if (ev.e === sourceEvent && ev.p && ev.p[field]) {
99
- const value = ev.p[field];
134
+ const value = ev.e === sourceEvent ? histogramValue(ev.p, field) : null;
135
+ if (value !== null) {
100
136
  histogram[value] = (histogram[value] || 0) + 1;
101
137
  }
102
138
  });
103
- return histogram;
139
+ // back to a plain object (fromEntries keeps a "__proto__" key as an ordinary key)
140
+ return Object.fromEntries(Object.entries(histogram));
104
141
  }
142
+ default:
143
+ throw new Error(`hearald: unknown formula "${definition.formula}"`);
105
144
  }
106
145
  }
107
146
 
108
- export function cacheFilePath(endDate, timeWindow) {
109
- const endDateIso = typeof endDate === "string" ? endDate : iso(endDate);
110
- return `events/summary:${endDateIso}:${timeWindow}.json`;
147
+ // A short fingerprint of what the numbers depend on: the metric definitions (not `ui`) and the
148
+ // format version. Changing a definition must not serve summaries computed with the old one.
149
+ export function cacheKey(statDefinitions) {
150
+ return createHash("sha1")
151
+ .update(
152
+ JSON.stringify([
153
+ CACHE_FORMAT_VERSION,
154
+ statDefinitions.raw_metrics,
155
+ statDefinitions.computed_metrics,
156
+ ])
157
+ )
158
+ .digest("hex")
159
+ .slice(0, 8);
160
+ }
161
+
162
+ export function cacheFilePath(endDate, timeWindow, statDefinitions) {
163
+ const endDateIso =
164
+ typeof endDate === "string" ? endDate : hearaldConfiguration.isoFn(endDate);
165
+ return path.join(
166
+ hearaldConfiguration.eventsDir,
167
+ `summary:${endDateIso}:${timeWindow}:${cacheKey(statDefinitions)}.json`
168
+ );
111
169
  }
112
170
 
113
- export async function readMetricsFromCache(endDateIso, timeWindow) {
114
- const filePath = cacheFilePath(endDateIso, timeWindow);
171
+ export async function readMetricsFromCache(
172
+ endDateIso,
173
+ timeWindow,
174
+ statDefinitions
175
+ ) {
176
+ const filePath = cacheFilePath(endDateIso, timeWindow, statDefinitions);
115
177
  try {
116
178
  return (await readFile(filePath)).toString("utf8");
117
179
  } catch (e) {
118
- if (e.message.indexOf("ENOENT") !== -1) {
180
+ if (e.code === "ENOENT") {
119
181
  return null;
120
182
  }
121
183
  throw e;
122
184
  }
123
185
  }
124
186
 
125
- export async function writeMetricsToCache(endDateIso, timeWindow, statsJson) {
126
- const filePath = cacheFilePath(endDateIso, timeWindow);
187
+ export async function writeMetricsToCache(
188
+ endDateIso,
189
+ timeWindow,
190
+ statsJson,
191
+ statDefinitions
192
+ ) {
193
+ const filePath = cacheFilePath(endDateIso, timeWindow, statDefinitions);
194
+ await mkdir(hearaldConfiguration.eventsDir, { recursive: true });
127
195
  return writeFile(filePath, statsJson);
128
196
  }
129
197
 
@@ -137,8 +205,17 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
137
205
  if (def.source_file_suffix) fileSuffixes.add(def.source_file_suffix);
138
206
  });
139
207
 
208
+ // Refuse a window too big for memory before reading any of it
209
+ let bytes = 0;
210
+ for (const suffix of fileSuffixes) {
211
+ bytes += await windowBytes(endDate, timeWindow, suffix || undefined);
212
+ }
213
+ if (bytes > hearaldConfiguration.maxWindowBytes) {
214
+ throw new WindowTooLargeError(bytes, hearaldConfiguration.maxWindowBytes);
215
+ }
216
+
140
217
  // Load events from all relevant files for raw metrics
141
- let allEvents = [];
218
+ let processedEventCount = 0;
142
219
  const eventsBySuffix = {};
143
220
  for (const suffix of fileSuffixes) {
144
221
  const events = await loadEventsOverWindow(
@@ -146,7 +223,7 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
146
223
  timeWindow,
147
224
  suffix || undefined
148
225
  );
149
- allEvents = allEvents.concat(events);
226
+ processedEventCount += events.length;
150
227
  eventsBySuffix[suffix || ""] = events;
151
228
  }
152
229
 
@@ -159,8 +236,18 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
159
236
  ];
160
237
  });
161
238
 
162
- // Store all events for computed metrics
163
- metrics._allEvents = allEvents;
239
+ // Only a computed histogram reads the raw events, so only then keep them all together
240
+ if (
241
+ Object.values(computedMetricDefinitions).some(
242
+ (def) => def.formula === "histogram"
243
+ )
244
+ ) {
245
+ const allEvents = [];
246
+ for (const events of Object.values(eventsBySuffix)) {
247
+ for (const event of events) allEvents.push(event);
248
+ }
249
+ metrics._allEvents = allEvents;
250
+ }
164
251
 
165
252
  let anyComputed = true;
166
253
  while (anyComputed) {
@@ -182,7 +269,7 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
182
269
  computedMetricDefinition,
183
270
  timeWindow
184
271
  );
185
- anyComputed = true;
272
+ anyComputed = typeof metrics[name] !== "undefined";
186
273
  }
187
274
  }
188
275
  }
@@ -213,9 +300,9 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
213
300
  });
214
301
  return {
215
302
  metrics: nestedMetrics,
216
- endDate: iso(endDate),
303
+ endDate: hearaldConfiguration.isoFn(endDate),
217
304
  timeWindow,
218
- processedEventCount: allEvents.length,
305
+ processedEventCount,
219
306
  };
220
307
  }
221
308
 
@@ -224,39 +311,81 @@ export async function handleAnalyticsDataRequest({
224
311
  response,
225
312
  statDefinitions,
226
313
  }) {
227
- const defaultEndDateIso = iso(defaultEndDate(nowAsPstDate));
314
+ const defaultEndDateIso = hearaldConfiguration.isoFn(
315
+ defaultEndDate(hearaldConfiguration.nowFn())
316
+ );
228
317
  const endDateIso = request.params.date
229
318
  ? request.params.date
230
319
  : defaultEndDateIso;
231
- const timeWindow = parseInt(request.params.last, 10);
232
- if (!timeWindow) {
233
- response.status(500).json({ Error: "invalid time window" });
320
+ // The date becomes part of a file name, so it must be a real calendar date and nothing else. The
321
+ // round trip rejects dates like 2026-02-31, which Date would quietly move to March.
322
+ if (
323
+ !/^\d{4}-\d{2}-\d{2}$/.test(endDateIso) ||
324
+ Number.isNaN(new Date(`${endDateIso}T00:00:00Z`).getTime()) ||
325
+ iso(new Date(`${endDateIso}T00:00:00Z`)) !== endDateIso
326
+ ) {
327
+ response.status(400).json({ error: "invalid date" });
234
328
  return;
235
329
  }
236
- const dataFromFile = await readMetricsFromCache(endDateIso, timeWindow);
237
- if (dataFromFile) {
330
+ const timeWindow = /^\d+$/.test(String(request.params.last))
331
+ ? parseInt(request.params.last, 10)
332
+ : 0;
333
+ if (!timeWindow || timeWindow > MAX_TIME_WINDOW_DAYS) {
334
+ response.status(400).json({ error: "invalid time window" });
335
+ return;
336
+ }
337
+ // A window that ends today (or later) is still filling up, so it is never cached.
338
+ const cacheable =
339
+ endDateIso < hearaldConfiguration.isoFn(hearaldConfiguration.nowFn());
340
+ const sendCached = (json) => {
238
341
  log.info(
239
342
  `Analytics for ${timeWindow} days ending on ${endDateIso} returned from cache`
240
343
  );
241
- response.status(200).type("application/json").send(dataFromFile);
344
+ response.status(200).type("application/json").send(json);
345
+ };
346
+ const dataFromFile = cacheable
347
+ ? await readMetricsFromCache(endDateIso, timeWindow, statDefinitions)
348
+ : null;
349
+ if (dataFromFile) {
350
+ sendCached(dataFromFile);
242
351
  return;
243
352
  }
244
- log.info(
245
- `Analytics for ${timeWindow} days ending on ${endDateIso} computing from events`
246
- );
247
353
  try {
248
- const stats = await generateStats(
249
- statDefinitions,
250
- new Date(endDateIso),
251
- timeWindow
252
- );
253
- await writeMetricsToCache(
254
- endDateIso,
255
- timeWindow,
256
- JSON.stringify(stats, null, 2)
257
- );
258
- response.status(200).type("application/json").send(JSON.stringify(stats));
354
+ const stats = await runExclusive(async () => {
355
+ // someone ahead of us in the queue may just have computed this same window
356
+ const cached = cacheable
357
+ ? await readMetricsFromCache(endDateIso, timeWindow, statDefinitions)
358
+ : null;
359
+ if (cached) return { cached };
360
+ log.info(
361
+ `Analytics for ${timeWindow} days ending on ${endDateIso} computing from events`
362
+ );
363
+ const computed = await generateStats(
364
+ statDefinitions,
365
+ new Date(`${endDateIso}T00:00:00Z`),
366
+ timeWindow
367
+ );
368
+ if (cacheable) {
369
+ await writeMetricsToCache(
370
+ endDateIso,
371
+ timeWindow,
372
+ JSON.stringify(computed, null, 2),
373
+ statDefinitions
374
+ );
375
+ }
376
+ return { computed };
377
+ });
378
+ if (stats.cached) {
379
+ sendCached(stats.cached);
380
+ return;
381
+ }
382
+ response.status(200).type("application/json").send(JSON.stringify(stats.computed));
259
383
  } catch (e) {
384
+ if (e instanceof WindowTooLargeError) {
385
+ log.warn(e.message);
386
+ response.status(413).json({ error: "window too large" });
387
+ return;
388
+ }
260
389
  log.error("failed to compute analytics", e);
261
390
  response
262
391
  .status(500)
@@ -271,32 +400,56 @@ const __dirname = path.dirname(__filename);
271
400
  log.info("filename is", __filename);
272
401
  log.info("dirname is", __dirname);
273
402
 
274
- export function createAnalyticsRouter({ statDefinitions }) {
403
+ /**
404
+ * @param {Object} options
405
+ * @param {Object} options.statDefinitions
406
+ * @param {function} options.auth - Express middleware that rejects anyone who may not see the stats
407
+ * (for example basicAuth from hearald). Required: the dashboard shows your usage numbers.
408
+ * @param {boolean} [options.allowUnauthenticated=false] - Skip `auth`. Only for local development.
409
+ */
410
+ export function createAnalyticsRouter({
411
+ statDefinitions,
412
+ auth,
413
+ allowUnauthenticated = false,
414
+ }) {
415
+ if (typeof auth !== "function" && !allowUnauthenticated) {
416
+ throw new Error(
417
+ "hearald: the analytics dashboard needs an `auth` middleware (see basicAuth), " +
418
+ "or allowUnauthenticated: true for local development"
419
+ );
420
+ }
275
421
  log.info("statManifest", statDefinitions);
276
422
  const router = express.Router();
423
+ if (typeof auth === "function") router.use(auth);
277
424
 
278
- router.get(`/data/:last/:date?`, async (request, response) => {
279
- log.info(chalk.cyan("[API] GET /analytics/data/:last/:date?"));
280
- await handleAnalyticsDataRequest({
281
- request,
282
- response,
283
- statDefinitions,
284
- loadEventsOverWindow,
285
- nowAsPstDate,
286
- });
287
- });
425
+ // Two routes rather than an optional `:date?`, which Express 5 (path-to-regexp 8) rejects.
426
+ // asyncHandler sends a failed read to the error handler; Express 4 would crash on it.
427
+ router.get(
428
+ ["/data/:last", "/data/:last/:date"],
429
+ asyncHandler(async (request, response) => {
430
+ log.info(chalk.cyan("[API] GET /analytics/data/:last/:date?"));
431
+ await handleAnalyticsDataRequest({
432
+ request,
433
+ response,
434
+ statDefinitions,
435
+ });
436
+ })
437
+ );
288
438
 
289
- router.get(`/layout`, async (req, res) => {
439
+ router.get(`/layout`, (req, res) => {
290
440
  res.json(statDefinitions.ui || {});
291
441
  });
292
442
 
293
- router.get(`/`, async (request, response) => {
294
- const fileContents = await readFile(
295
- path.join(__dirname, "..", "..", "dist", "analytics.html")
296
- );
297
- const analyticsHtml = fileContents.toString("utf-8");
298
- response.send(analyticsHtml);
299
- });
443
+ router.get(
444
+ `/`,
445
+ asyncHandler(async (request, response) => {
446
+ const fileContents = await readFile(
447
+ path.join(__dirname, "..", "..", "dist", "analytics.html")
448
+ );
449
+ const analyticsHtml = fileContents.toString("utf-8");
450
+ response.send(analyticsHtml);
451
+ })
452
+ );
300
453
 
301
454
  return router;
302
455
  }
@@ -0,0 +1,6 @@
1
+ // Express 4 does not catch a rejected promise from an async handler, so one failed read would be an
2
+ // unhandled rejection that kills the process. Express 5 does, but hearald supports both.
3
+ export const asyncHandler = (handler) => (req, res, next) =>
4
+ Promise.resolve()
5
+ .then(() => handler(req, res, next))
6
+ .catch(next);
@@ -0,0 +1,89 @@
1
+ import { timingSafeEqual } from "crypto";
2
+ import log from "./logger.js";
3
+ import { asyncHandler } from "./asyncHandler.js";
4
+
5
+ // Constant-time string comparison.
6
+ export function safeEqual(a, b) {
7
+ const x = Buffer.from(String(a));
8
+ const y = Buffer.from(String(b));
9
+ return x.length === y.length && timingSafeEqual(x, y);
10
+ }
11
+
12
+ /**
13
+ * HTTP Basic authentication for the analytics dashboard.
14
+ *
15
+ * Wrong guesses are counted per `req.ip`. That is the caller's address only when your app sets
16
+ * Express's `trust proxy` to match your setup: behind a reverse proxy without it, every visitor
17
+ * shares the proxy's address (one attacker locks everybody out), and with a `trust proxy` that
18
+ * trusts too much a caller can send a forged X-Forwarded-For to dodge the lockout.
19
+ *
20
+ * @param {Object} options
21
+ * @param {(username: string, password: string) => boolean | Promise<boolean>} options.verify
22
+ * Decides whether the credentials are right. Compare with a constant-time function, and store a
23
+ * hash of the password, never the password. If it throws, the request gets a 500 (and is not
24
+ * counted as a wrong guess).
25
+ * @param {string} [options.realm="hearald"]
26
+ * @param {number} [options.maxFailures=10] wrong guesses allowed per IP in the window, before 429
27
+ * @param {number} [options.windowMs=900000] the window, 15 minutes by default
28
+ * @param {number} [options.maxTrackedIps=10000] the most addresses remembered at once, so a flood of
29
+ * distinct addresses cannot grow memory without limit (the oldest are forgotten first)
30
+ */
31
+ export function basicAuth({ verify, realm = "hearald", maxFailures = 10, windowMs = 15 * 60 * 1000, maxTrackedIps = 10000, now = Date.now } = {}) {
32
+ if (typeof verify !== "function") throw new Error("basicAuth needs a verify(username, password) function");
33
+ const failures = new Map(); // ip -> { count, resetAt }, oldest first
34
+
35
+ function prune(t) {
36
+ for (const [ip, entry] of failures) {
37
+ if (entry.resetAt <= t) failures.delete(ip);
38
+ }
39
+ // Still too many (a burst inside one window): forget the oldest
40
+ for (const ip of failures.keys()) {
41
+ if (failures.size < maxTrackedIps) break;
42
+ failures.delete(ip);
43
+ }
44
+ }
45
+
46
+ return asyncHandler(async function basicAuthMiddleware(req, res, next) {
47
+ const ip = req.ip || "unknown";
48
+ const t = now();
49
+ let entry = failures.get(ip);
50
+ if (entry && entry.resetAt <= t) {
51
+ failures.delete(ip);
52
+ entry = undefined;
53
+ }
54
+ if (entry && entry.count >= maxFailures) {
55
+ res.set("Retry-After", String(Math.ceil((entry.resetAt - t) / 1000)));
56
+ return res.status(429).send("Too many attempts");
57
+ }
58
+
59
+ // The scheme name is case-insensitive (RFC 7235)
60
+ const match = /^basic\s+([A-Za-z0-9+/=]+)\s*$/i.exec(req.get("authorization") || "");
61
+ if (match) {
62
+ // Count the attempt now, before the (possibly slow) verify: if it were counted afterwards,
63
+ // any number of parallel guesses would all be checked against a count of zero.
64
+ if (!entry) {
65
+ prune(t);
66
+ entry = { count: 0, resetAt: t + windowMs };
67
+ failures.set(ip, entry);
68
+ }
69
+ entry.count += 1;
70
+
71
+ const decoded = Buffer.from(match[1], "base64").toString("utf8");
72
+ const colon = decoded.indexOf(":");
73
+ let ok = false;
74
+ try {
75
+ ok = colon > -1 && (await verify(decoded.slice(0, colon), decoded.slice(colon + 1)));
76
+ } catch (err) {
77
+ entry.count -= 1; // a broken verify is not the caller's wrong guess
78
+ log.error("basicAuth: verify threw", err);
79
+ return res.status(500).send("Authentication error");
80
+ }
81
+ if (ok) {
82
+ failures.delete(ip);
83
+ return next();
84
+ }
85
+ }
86
+ res.set("WWW-Authenticate", `Basic realm="${realm}", charset="UTF-8"`);
87
+ return res.status(401).send("Authentication required");
88
+ });
89
+ }
@@ -0,0 +1,53 @@
1
+ import { iso, nowAsPstDate } from "../shared/utils.js";
2
+
3
+ class Configuration {
4
+ constructor({
5
+ nowFn = nowAsPstDate,
6
+ isoFn = iso,
7
+ logLevel = "warn",
8
+ getUserId = (req) => req.user?.id,
9
+ eventsDir = "events",
10
+ idleCloseMs = 60 * 1000,
11
+ maxWindowBytes = 64 * 1024 * 1024,
12
+ } = {}) {
13
+ this.nowFn = nowFn;
14
+ this.isoFn = isoFn;
15
+ this.logLevel = logLevel;
16
+ this.getUserId = getUserId;
17
+ this.eventsDir = eventsDir;
18
+ this.idleCloseMs = idleCloseMs;
19
+ this.maxWindowBytes = maxWindowBytes;
20
+ }
21
+
22
+ setLogLevel(level) {
23
+ this.logLevel = level;
24
+ }
25
+
26
+ setNowFn(nowFn) {
27
+ this.nowFn = nowFn;
28
+ }
29
+
30
+ setGetUserId(getUserId) {
31
+ this.getUserId = getUserId;
32
+ }
33
+
34
+ setEventsDir(eventsDir) {
35
+ this.eventsDir = eventsDir;
36
+ }
37
+
38
+ // How long an event file stays open after its last write
39
+ setIdleCloseMs(idleCloseMs) {
40
+ this.idleCloseMs = idleCloseMs;
41
+ }
42
+
43
+ // The most event-log bytes one dashboard request may read
44
+ setMaxWindowBytes(maxWindowBytes) {
45
+ this.maxWindowBytes = maxWindowBytes;
46
+ }
47
+
48
+ setIsoFn(isoFn) {
49
+ this.isoFn = isoFn;
50
+ }
51
+ }
52
+
53
+ export default new Configuration();
@@ -1,28 +1,27 @@
1
+ import hearaldConfiguration from "./configuration.js";
2
+
3
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
4
+
1
5
  class Logger {
2
- constructor(level) {
3
- this.level = level || "info";
6
+ constructor(config = hearaldConfiguration) {
7
+ this.config = config;
4
8
  }
5
9
 
6
- setLogLevel(level) {
7
- this.level = level;
10
+ enabled(level) {
11
+ const configured = LEVELS[this.config.logLevel] ?? LEVELS.warn;
12
+ return LEVELS[level] >= configured;
8
13
  }
9
14
 
10
15
  debug(...args) {
11
- if (this.level === "debug") {
12
- console.log("[DEBUG]", ...args);
13
- }
16
+ if (this.enabled("debug")) console.log("[DEBUG]", ...args);
14
17
  }
15
18
 
16
19
  info(...args) {
17
- if (this.level === "debug" || this.level === "info") {
18
- console.log("[INFO]", ...args);
19
- }
20
+ if (this.enabled("info")) console.log("[INFO]", ...args);
20
21
  }
21
22
 
22
23
  warn(...args) {
23
- if (this.level !== "error") {
24
- console.warn("[WARN]", ...args);
25
- }
24
+ if (this.enabled("warn")) console.warn("[WARN]", ...args);
26
25
  }
27
26
 
28
27
  error(...args) {
@@ -30,4 +29,5 @@ class Logger {
30
29
  }
31
30
  }
32
31
 
33
- export default new Logger("error");
32
+ export { Logger };
33
+ export default new Logger(hearaldConfiguration);