hearald 0.0.5 → 0.1.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.
@@ -1,13 +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
9
  import hearaldConfiguration from "./configuration.js";
9
- import { omitProperties, defaultEndDate } from "../shared/utils.js";
10
- import { loadEventsOverWindow } from "./serverEvents.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
+ };
11
42
 
12
43
  export function generateRawMetrics(events, rawMetricDefinitions) {
13
44
  const metrics = {};
@@ -18,12 +49,14 @@ export function generateRawMetrics(events, rawMetricDefinitions) {
18
49
  } else if (rawMetricDefinition.aggregator === "set_of_users") {
19
50
  metrics[name] = new Set();
20
51
  } else if (rawMetricDefinition.aggregator === "histogram") {
21
- metrics[name] = {};
52
+ // no prototype, so a value named "constructor" or "__proto__" is just a key
53
+ metrics[name] = Object.create(null);
22
54
  }
23
55
  }
24
56
  );
25
57
 
26
- events.forEach(({ e, p, u }) => {
58
+ events.forEach(({ e, p: eventParams, u }) => {
59
+ const p = eventParams || {};
27
60
  Object.entries(rawMetricDefinitions).forEach(
28
61
  ([name, rawMetricDefinition]) => {
29
62
  if (rawMetricDefinition.filter) {
@@ -32,19 +65,26 @@ export function generateRawMetrics(events, rawMetricDefinitions) {
32
65
  );
33
66
  if (!match) return;
34
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
+ }
35
76
  if (
36
77
  rawMetricDefinition.aggregator === "count" &&
37
78
  rawMetricDefinition.event === e
38
79
  ) {
39
80
  metrics[name] += 1;
40
81
  } else if (rawMetricDefinition.aggregator === "set_of_users") {
41
- metrics[name].add(u);
82
+ if (u !== null && u !== undefined) metrics[name].add(u);
42
83
  } else if (
43
84
  rawMetricDefinition.aggregator === "histogram" &&
44
85
  rawMetricDefinition.event === e
45
86
  ) {
46
- const field = rawMetricDefinition.field;
47
- const value = p && p[field] ? p[field] : null;
87
+ const value = histogramValue(p, rawMetricDefinition.field);
48
88
  if (value !== null) {
49
89
  metrics[name][value] = (metrics[name][value] || 0) + 1;
50
90
  }
@@ -88,39 +128,70 @@ export function generateComputedMetric(metrics, definition, timeWindow) {
88
128
  // Compute histogram for given event type and field
89
129
  const sourceEvent = definition.source;
90
130
  const field = definition.field;
91
- const histogram = {};
131
+ const histogram = Object.create(null);
92
132
  // Find all events of the given type
93
133
  (metrics._allEvents || []).forEach((ev) => {
94
- if (ev.e === sourceEvent && ev.p && ev.p[field]) {
95
- const value = ev.p[field];
134
+ const value = ev.e === sourceEvent ? histogramValue(ev.p, field) : null;
135
+ if (value !== null) {
96
136
  histogram[value] = (histogram[value] || 0) + 1;
97
137
  }
98
138
  });
99
- return histogram;
139
+ // back to a plain object (fromEntries keeps a "__proto__" key as an ordinary key)
140
+ return Object.fromEntries(Object.entries(histogram));
100
141
  }
142
+ default:
143
+ throw new Error(`hearald: unknown formula "${definition.formula}"`);
101
144
  }
102
145
  }
103
146
 
104
- export function cacheFilePath(endDate, timeWindow) {
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) {
105
163
  const endDateIso =
106
164
  typeof endDate === "string" ? endDate : hearaldConfiguration.isoFn(endDate);
107
- return `events/summary:${endDateIso}:${timeWindow}.json`;
165
+ return path.join(
166
+ hearaldConfiguration.eventsDir,
167
+ `summary:${endDateIso}:${timeWindow}:${cacheKey(statDefinitions)}.json`
168
+ );
108
169
  }
109
170
 
110
- export async function readMetricsFromCache(endDateIso, timeWindow) {
111
- const filePath = cacheFilePath(endDateIso, timeWindow);
171
+ export async function readMetricsFromCache(
172
+ endDateIso,
173
+ timeWindow,
174
+ statDefinitions
175
+ ) {
176
+ const filePath = cacheFilePath(endDateIso, timeWindow, statDefinitions);
112
177
  try {
113
178
  return (await readFile(filePath)).toString("utf8");
114
179
  } catch (e) {
115
- if (e.message.indexOf("ENOENT") !== -1) {
180
+ if (e.code === "ENOENT") {
116
181
  return null;
117
182
  }
118
183
  throw e;
119
184
  }
120
185
  }
121
186
 
122
- export async function writeMetricsToCache(endDateIso, timeWindow, statsJson) {
123
- 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 });
124
195
  return writeFile(filePath, statsJson);
125
196
  }
126
197
 
@@ -134,8 +205,17 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
134
205
  if (def.source_file_suffix) fileSuffixes.add(def.source_file_suffix);
135
206
  });
136
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
+
137
217
  // Load events from all relevant files for raw metrics
138
- let allEvents = [];
218
+ let processedEventCount = 0;
139
219
  const eventsBySuffix = {};
140
220
  for (const suffix of fileSuffixes) {
141
221
  const events = await loadEventsOverWindow(
@@ -143,7 +223,7 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
143
223
  timeWindow,
144
224
  suffix || undefined
145
225
  );
146
- allEvents = allEvents.concat(events);
226
+ processedEventCount += events.length;
147
227
  eventsBySuffix[suffix || ""] = events;
148
228
  }
149
229
 
@@ -156,8 +236,18 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
156
236
  ];
157
237
  });
158
238
 
159
- // Store all events for computed metrics
160
- 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
+ }
161
251
 
162
252
  let anyComputed = true;
163
253
  while (anyComputed) {
@@ -179,7 +269,7 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
179
269
  computedMetricDefinition,
180
270
  timeWindow
181
271
  );
182
- anyComputed = true;
272
+ anyComputed = typeof metrics[name] !== "undefined";
183
273
  }
184
274
  }
185
275
  }
@@ -212,7 +302,7 @@ export async function generateStats(statDefinitions, endDate, timeWindow) {
212
302
  metrics: nestedMetrics,
213
303
  endDate: hearaldConfiguration.isoFn(endDate),
214
304
  timeWindow,
215
- processedEventCount: allEvents.length,
305
+ processedEventCount,
216
306
  };
217
307
  }
218
308
 
@@ -221,39 +311,81 @@ export async function handleAnalyticsDataRequest({
221
311
  response,
222
312
  statDefinitions,
223
313
  }) {
224
- const defaultEndDateIso = hearaldConfiguration.isoFn(defaultEndDate());
314
+ const defaultEndDateIso = hearaldConfiguration.isoFn(
315
+ defaultEndDate(hearaldConfiguration.nowFn())
316
+ );
225
317
  const endDateIso = request.params.date
226
318
  ? request.params.date
227
319
  : defaultEndDateIso;
228
- const timeWindow = parseInt(request.params.last, 10);
229
- if (!timeWindow) {
230
- 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" });
231
328
  return;
232
329
  }
233
- const dataFromFile = await readMetricsFromCache(endDateIso, timeWindow);
234
- 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) => {
235
341
  log.info(
236
342
  `Analytics for ${timeWindow} days ending on ${endDateIso} returned from cache`
237
343
  );
238
- 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);
239
351
  return;
240
352
  }
241
- log.info(
242
- `Analytics for ${timeWindow} days ending on ${endDateIso} computing from events`
243
- );
244
353
  try {
245
- const stats = await generateStats(
246
- statDefinitions,
247
- new Date(endDateIso),
248
- timeWindow
249
- );
250
- await writeMetricsToCache(
251
- endDateIso,
252
- timeWindow,
253
- JSON.stringify(stats, null, 2)
254
- );
255
- 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));
256
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
+ }
257
389
  log.error("failed to compute analytics", e);
258
390
  response
259
391
  .status(500)
@@ -268,31 +400,56 @@ const __dirname = path.dirname(__filename);
268
400
  log.info("filename is", __filename);
269
401
  log.info("dirname is", __dirname);
270
402
 
271
- 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
+ }
272
421
  log.info("statManifest", statDefinitions);
273
422
  const router = express.Router();
423
+ if (typeof auth === "function") router.use(auth);
274
424
 
275
- router.get(`/data/:last/:date?`, async (request, response) => {
276
- log.info(chalk.cyan("[API] GET /analytics/data/:last/:date?"));
277
- await handleAnalyticsDataRequest({
278
- request,
279
- response,
280
- statDefinitions,
281
- loadEventsOverWindow,
282
- });
283
- });
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
+ );
284
438
 
285
- router.get(`/layout`, async (req, res) => {
439
+ router.get(`/layout`, (req, res) => {
286
440
  res.json(statDefinitions.ui || {});
287
441
  });
288
442
 
289
- router.get(`/`, async (request, response) => {
290
- const fileContents = await readFile(
291
- path.join(__dirname, "..", "..", "dist", "analytics.html")
292
- );
293
- const analyticsHtml = fileContents.toString("utf-8");
294
- response.send(analyticsHtml);
295
- });
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
+ );
296
453
 
297
454
  return router;
298
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
+ }
@@ -4,13 +4,19 @@ class Configuration {
4
4
  constructor({
5
5
  nowFn = nowAsPstDate,
6
6
  isoFn = iso,
7
- logLevel = "info",
7
+ logLevel = "warn",
8
8
  getUserId = (req) => req.user?.id,
9
+ eventsDir = "events",
10
+ idleCloseMs = 60 * 1000,
11
+ maxWindowBytes = 64 * 1024 * 1024,
9
12
  } = {}) {
10
13
  this.nowFn = nowFn;
11
14
  this.isoFn = isoFn;
12
15
  this.logLevel = logLevel;
13
16
  this.getUserId = getUserId;
17
+ this.eventsDir = eventsDir;
18
+ this.idleCloseMs = idleCloseMs;
19
+ this.maxWindowBytes = maxWindowBytes;
14
20
  }
15
21
 
16
22
  setLogLevel(level) {
@@ -25,6 +31,20 @@ class Configuration {
25
31
  this.getUserId = getUserId;
26
32
  }
27
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
+
28
48
  setIsoFn(isoFn) {
29
49
  this.isoFn = isoFn;
30
50
  }
@@ -1,26 +1,27 @@
1
1
  import hearaldConfiguration from "./configuration.js";
2
2
 
3
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
4
+
3
5
  class Logger {
4
6
  constructor(config = hearaldConfiguration) {
5
7
  this.config = config;
6
8
  }
7
9
 
10
+ enabled(level) {
11
+ const configured = LEVELS[this.config.logLevel] ?? LEVELS.warn;
12
+ return LEVELS[level] >= configured;
13
+ }
14
+
8
15
  debug(...args) {
9
- if (this.config.logLevel === "debug") {
10
- console.log("[DEBUG]", ...args);
11
- }
16
+ if (this.enabled("debug")) console.log("[DEBUG]", ...args);
12
17
  }
13
18
 
14
19
  info(...args) {
15
- if (this.config.logLevel === "debug" || this.config.logLevel === "info") {
16
- console.log("[INFO]", ...args);
17
- }
20
+ if (this.enabled("info")) console.log("[INFO]", ...args);
18
21
  }
19
22
 
20
23
  warn(...args) {
21
- if (this.config.logLevel !== "error") {
22
- console.warn("[WARN]", ...args);
23
- }
24
+ if (this.enabled("warn")) console.warn("[WARN]", ...args);
24
25
  }
25
26
 
26
27
  error(...args) {
@@ -28,4 +29,5 @@ class Logger {
28
29
  }
29
30
  }
30
31
 
31
- export default new Logger("error");
32
+ export { Logger };
33
+ export default new Logger(hearaldConfiguration);