@xiboplayer/stats 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.
@@ -0,0 +1,541 @@
1
+ /**
2
+ * LogReporter - CMS logging for Xibo Players
3
+ *
4
+ * Collects and submits logs to CMS via XMDS.
5
+ * Uses IndexedDB for persistent storage across sessions.
6
+ *
7
+ * @module @xiboplayer/stats/logger
8
+ */
9
+
10
+ import { createLogger } from '@xiboplayer/utils';
11
+
12
+ const log = createLogger('@xiboplayer/stats');
13
+
14
+ // IndexedDB configuration
15
+ const DB_NAME = 'xibo-player-logs';
16
+ const DB_VERSION = 1;
17
+ const LOGS_STORE = 'logs';
18
+
19
+ /**
20
+ * Log reporter for CMS logging
21
+ *
22
+ * Stores log entries in IndexedDB and submits to CMS via XMDS.
23
+ * Supports multiple log levels: error, audit, info, debug.
24
+ *
25
+ * @example
26
+ * const reporter = new LogReporter();
27
+ * await reporter.init();
28
+ *
29
+ * // Log messages
30
+ * await reporter.error('Failed to load layout', 'PLAYER');
31
+ * await reporter.info('Layout loaded successfully', 'PLAYER');
32
+ *
33
+ * // Get logs for submission
34
+ * const logs = await reporter.getLogsForSubmission(100);
35
+ * const xml = formatLogs(logs);
36
+ * // ... submit to CMS ...
37
+ * await reporter.clearSubmittedLogs(logs);
38
+ */
39
+ export class LogReporter {
40
+ constructor() {
41
+ this.db = null;
42
+ this._reportedFaults = new Map(); // code -> timestamp (deduplication)
43
+ }
44
+
45
+ /**
46
+ * Initialize IndexedDB
47
+ *
48
+ * Creates logs store with index on 'submitted' field for fast queries.
49
+ * Safe to call multiple times (idempotent).
50
+ *
51
+ * @returns {Promise<void>}
52
+ * @throws {Error} If IndexedDB is not available or initialization fails
53
+ */
54
+ async init() {
55
+ if (this.db) {
56
+ log.debug('Log reporter already initialized');
57
+ return;
58
+ }
59
+
60
+ return new Promise((resolve, reject) => {
61
+ // Check if IndexedDB is available
62
+ if (typeof indexedDB === 'undefined') {
63
+ const error = new Error('IndexedDB not available');
64
+ log.error('IndexedDB not available - logs will not be persisted');
65
+ reject(error);
66
+ return;
67
+ }
68
+
69
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
70
+
71
+ request.onerror = () => {
72
+ const error = new Error(`Failed to open IndexedDB: ${request.error}`);
73
+ log.error('Failed to open logs database:', request.error);
74
+ reject(error);
75
+ };
76
+
77
+ request.onsuccess = () => {
78
+ this.db = request.result;
79
+ log.info('Logs database initialized');
80
+ resolve();
81
+ };
82
+
83
+ request.onupgradeneeded = (event) => {
84
+ const db = event.target.result;
85
+
86
+ // Create logs store if it doesn't exist
87
+ if (!db.objectStoreNames.contains(LOGS_STORE)) {
88
+ const store = db.createObjectStore(LOGS_STORE, {
89
+ keyPath: 'id',
90
+ autoIncrement: true
91
+ });
92
+
93
+ // Index on 'submitted' for fast queries
94
+ store.createIndex('submitted', 'submitted', { unique: false });
95
+
96
+ log.info('Logs store created');
97
+ }
98
+ };
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Log a message
104
+ *
105
+ * Stores a log entry in IndexedDB for later submission to CMS.
106
+ *
107
+ * @param {string} level - Log level: 'error', 'audit', 'info', or 'debug'
108
+ * @param {string} message - Log message
109
+ * @param {string} category - Log category (default: 'PLAYER')
110
+ * @param {Object} [extra] - Optional extra fields (alertType, eventType)
111
+ * @returns {Promise<void>}
112
+ */
113
+ async log(level, message, category = 'PLAYER', extra = null) {
114
+ if (!this.db) {
115
+ // Use console directly — NOT the logger — to avoid infinite feedback loop.
116
+ // The logger dispatches to log sinks, and this method IS the sink target.
117
+ console.warn('[LogReporter] Database not initialized, dropping log entry');
118
+ return;
119
+ }
120
+
121
+ // Validate log level
122
+ const validLevels = ['error', 'warning', 'audit', 'info', 'debug'];
123
+ if (!validLevels.includes(level)) {
124
+ level = 'info';
125
+ }
126
+
127
+ const logEntry = {
128
+ level,
129
+ message,
130
+ category,
131
+ timestamp: new Date(),
132
+ submitted: 0 // Use 0/1 instead of boolean for IndexedDB compatibility
133
+ };
134
+
135
+ // Add alert fields for faults (triggers CMS dashboard alerts)
136
+ if (extra) {
137
+ if (extra.alertType) logEntry.alertType = extra.alertType;
138
+ if (extra.eventType) logEntry.eventType = extra.eventType;
139
+ }
140
+
141
+ try {
142
+ await this._saveLog(logEntry);
143
+ // NOTE: Do NOT call log.debug() here — it dispatches to sinks, which call
144
+ // logReporter.log() again, creating an infinite async loop.
145
+ } catch (error) {
146
+ // Use console directly to avoid feedback loop
147
+ console.error('[LogReporter] Failed to save log entry:', error);
148
+ throw error;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Report a fault to CMS (special log entry that triggers alerts)
154
+ *
155
+ * Faults are log entries with alertType/eventType fields that cause the
156
+ * CMS to show alerts on the display dashboard and optionally send emails.
157
+ * Deduplicates by code: same fault code won't be reported again within
158
+ * the cooldown period (default 5 minutes).
159
+ *
160
+ * @param {string} code - Fault code (e.g., 'LAYOUT_LOAD_FAILED')
161
+ * @param {string} reason - Human-readable description
162
+ * @param {number} [cooldownMs=300000] - Dedup cooldown in ms (default 5 min)
163
+ * @returns {Promise<void>}
164
+ */
165
+ async reportFault(code, reason, cooldownMs = 300000) {
166
+ // Deduplication: skip if same code was reported recently
167
+ const lastReported = this._reportedFaults.get(code);
168
+ if (lastReported && (Date.now() - lastReported) < cooldownMs) {
169
+ return;
170
+ }
171
+
172
+ this._reportedFaults.set(code, Date.now());
173
+
174
+ await this.log('error', reason, 'PLAYER', {
175
+ alertType: 'Player Fault',
176
+ eventType: code
177
+ });
178
+
179
+ log.info(`Fault reported: ${code} - ${reason}`);
180
+ }
181
+
182
+ /**
183
+ * Log an error message
184
+ *
185
+ * Shorthand for log('error', message, category)
186
+ *
187
+ * @param {string} message - Error message
188
+ * @param {string} category - Log category (default: 'PLAYER')
189
+ * @returns {Promise<void>}
190
+ */
191
+ async error(message, category = 'PLAYER') {
192
+ return this.log('error', message, category);
193
+ }
194
+
195
+ /**
196
+ * Log an audit message
197
+ *
198
+ * Shorthand for log('audit', message, category)
199
+ *
200
+ * @param {string} message - Audit message
201
+ * @param {string} category - Log category (default: 'PLAYER')
202
+ * @returns {Promise<void>}
203
+ */
204
+ async audit(message, category = 'PLAYER') {
205
+ return this.log('audit', message, category);
206
+ }
207
+
208
+ /**
209
+ * Log an info message
210
+ *
211
+ * Shorthand for log('info', message, category)
212
+ *
213
+ * @param {string} message - Info message
214
+ * @param {string} category - Log category (default: 'PLAYER')
215
+ * @returns {Promise<void>}
216
+ */
217
+ async info(message, category = 'PLAYER') {
218
+ return this.log('info', message, category);
219
+ }
220
+
221
+ /**
222
+ * Log a debug message
223
+ *
224
+ * Shorthand for log('debug', message, category)
225
+ *
226
+ * @param {string} message - Debug message
227
+ * @param {string} category - Log category (default: 'PLAYER')
228
+ * @returns {Promise<void>}
229
+ */
230
+ async debug(message, category = 'PLAYER') {
231
+ return this.log('debug', message, category);
232
+ }
233
+
234
+ /**
235
+ * Get logs ready for submission to CMS
236
+ *
237
+ * Returns unsubmitted logs up to the specified limit.
238
+ * Logs are ordered by ID (oldest first).
239
+ *
240
+ * @param {number} limit - Maximum number of logs to return (default: 100)
241
+ * @returns {Promise<Array>} Array of log objects
242
+ */
243
+ async getLogsForSubmission(limit = 100) {
244
+ if (!this.db) {
245
+ log.warn('Logs database not initialized');
246
+ return [];
247
+ }
248
+
249
+ return new Promise((resolve, reject) => {
250
+ const transaction = this.db.transaction([LOGS_STORE], 'readonly');
251
+ const store = transaction.objectStore(LOGS_STORE);
252
+ const index = store.index('submitted');
253
+
254
+ // Query for unsubmitted logs (0 = false)
255
+ const request = index.openCursor(IDBKeyRange.only(0));
256
+ const logs = [];
257
+
258
+ request.onsuccess = (event) => {
259
+ const cursor = event.target.result;
260
+
261
+ if (cursor && logs.length < limit) {
262
+ logs.push(cursor.value);
263
+ cursor.continue();
264
+ } else {
265
+ log.debug(`Retrieved ${logs.length} unsubmitted logs`);
266
+ resolve(logs);
267
+ }
268
+ };
269
+
270
+ request.onerror = () => {
271
+ log.error('Failed to retrieve logs:', request.error);
272
+ reject(new Error(`Failed to retrieve logs: ${request.error}`));
273
+ };
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Clear submitted logs from database
279
+ *
280
+ * Deletes logs that were successfully submitted to CMS.
281
+ *
282
+ * @param {Array} logs - Array of log objects to delete
283
+ * @returns {Promise<void>}
284
+ */
285
+ async clearSubmittedLogs(logs) {
286
+ if (!this.db) {
287
+ log.warn('Logs database not initialized');
288
+ return;
289
+ }
290
+
291
+ if (!logs || logs.length === 0) {
292
+ return;
293
+ }
294
+
295
+ return new Promise((resolve, reject) => {
296
+ const transaction = this.db.transaction([LOGS_STORE], 'readwrite');
297
+ const store = transaction.objectStore(LOGS_STORE);
298
+
299
+ let deletedCount = 0;
300
+
301
+ logs.forEach((logEntry) => {
302
+ if (logEntry.id) {
303
+ const request = store.delete(logEntry.id);
304
+ request.onsuccess = () => {
305
+ deletedCount++;
306
+ };
307
+ request.onerror = () => {
308
+ log.error(`Failed to delete log ${logEntry.id}:`, request.error);
309
+ };
310
+ }
311
+ });
312
+
313
+ transaction.oncomplete = () => {
314
+ log.debug(`Deleted ${deletedCount} submitted logs`);
315
+ resolve();
316
+ };
317
+
318
+ transaction.onerror = () => {
319
+ log.error('Failed to delete submitted logs:', transaction.error);
320
+ reject(new Error(`Failed to delete logs: ${transaction.error}`));
321
+ };
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Get all logs (for debugging)
327
+ *
328
+ * @returns {Promise<Array>} All logs in database
329
+ */
330
+ async getAllLogs() {
331
+ if (!this.db) {
332
+ log.warn('Logs database not initialized');
333
+ return [];
334
+ }
335
+
336
+ return new Promise((resolve, reject) => {
337
+ const transaction = this.db.transaction([LOGS_STORE], 'readonly');
338
+ const store = transaction.objectStore(LOGS_STORE);
339
+ const request = store.getAll();
340
+
341
+ request.onsuccess = () => {
342
+ resolve(request.result);
343
+ };
344
+
345
+ request.onerror = () => {
346
+ log.error('Failed to get all logs:', request.error);
347
+ reject(new Error(`Failed to get all logs: ${request.error}`));
348
+ };
349
+ });
350
+ }
351
+
352
+ /**
353
+ * Clear all logs (for testing)
354
+ *
355
+ * @returns {Promise<void>}
356
+ */
357
+ async clearAllLogs() {
358
+ if (!this.db) {
359
+ log.warn('Logs database not initialized');
360
+ return;
361
+ }
362
+
363
+ return new Promise((resolve, reject) => {
364
+ const transaction = this.db.transaction([LOGS_STORE], 'readwrite');
365
+ const store = transaction.objectStore(LOGS_STORE);
366
+ const request = store.clear();
367
+
368
+ request.onsuccess = () => {
369
+ log.debug('Cleared all logs');
370
+ resolve();
371
+ };
372
+
373
+ request.onerror = () => {
374
+ log.error('Failed to clear all logs:', request.error);
375
+ reject(new Error(`Failed to clear logs: ${request.error}`));
376
+ };
377
+ });
378
+ }
379
+
380
+ /**
381
+ * Save a log entry to IndexedDB
382
+ * @private
383
+ */
384
+ async _saveLog(logEntry) {
385
+ return new Promise((resolve, reject) => {
386
+ const transaction = this.db.transaction([LOGS_STORE], 'readwrite');
387
+ const store = transaction.objectStore(LOGS_STORE);
388
+ const request = store.add(logEntry);
389
+
390
+ request.onsuccess = () => {
391
+ resolve(request.result);
392
+ };
393
+
394
+ request.onerror = () => {
395
+ // Check for quota exceeded error
396
+ if (request.error.name === 'QuotaExceededError') {
397
+ console.warn('[LogReporter] IndexedDB quota exceeded - cleaning old logs');
398
+ this._cleanOldLogs().then(() => {
399
+ // Retry once after cleanup
400
+ const retryRequest = store.add(logEntry);
401
+ retryRequest.onsuccess = () => resolve(retryRequest.result);
402
+ retryRequest.onerror = () => reject(retryRequest.error);
403
+ }).catch(reject);
404
+ } else {
405
+ reject(request.error);
406
+ }
407
+ };
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Clean old logs when quota is exceeded
413
+ * Deletes oldest 100 submitted logs
414
+ * @private
415
+ */
416
+ async _cleanOldLogs() {
417
+ if (!this.db) {
418
+ return;
419
+ }
420
+
421
+ return new Promise((resolve, reject) => {
422
+ const transaction = this.db.transaction([LOGS_STORE], 'readwrite');
423
+ const store = transaction.objectStore(LOGS_STORE);
424
+ const index = store.index('submitted');
425
+
426
+ // Get oldest 100 submitted logs (use 1 for boolean true in IndexedDB)
427
+ const request = index.openCursor(1);
428
+ const toDelete = [];
429
+
430
+ request.onsuccess = (event) => {
431
+ const cursor = event.target.result;
432
+
433
+ if (cursor && toDelete.length < 100) {
434
+ toDelete.push(cursor.value.id);
435
+ cursor.continue();
436
+ } else {
437
+ // Delete collected IDs
438
+ toDelete.forEach((id) => {
439
+ store.delete(id);
440
+ });
441
+
442
+ console.log(`[LogReporter] Cleaned ${toDelete.length} old logs due to quota`);
443
+ resolve();
444
+ }
445
+ };
446
+
447
+ request.onerror = () => {
448
+ console.error('[LogReporter] Failed to clean old logs:', request.error);
449
+ reject(request.error);
450
+ };
451
+ });
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Format logs as XML for XMDS submission
457
+ *
458
+ * Converts array of log objects to XML format expected by CMS.
459
+ *
460
+ * XML format:
461
+ * ```xml
462
+ * <logs>
463
+ * <log date="2026-02-10 12:00:00" category="PLAYER" type="error"
464
+ * message="Failed to load layout 123" />
465
+ * </logs>
466
+ * ```
467
+ *
468
+ * @param {Array} logs - Array of log objects from getLogsForSubmission()
469
+ * @returns {string} XML string for XMDS SubmitLog
470
+ *
471
+ * @example
472
+ * const logs = await reporter.getLogsForSubmission(100);
473
+ * const xml = formatLogs(logs);
474
+ * await xmds.submitLog(xml);
475
+ */
476
+ export function formatLogs(logs) {
477
+ if (!logs || logs.length === 0) {
478
+ return '<logs></logs>';
479
+ }
480
+
481
+ const logElements = logs.map((logEntry) => {
482
+ // Format date as "YYYY-MM-DD HH:MM:SS"
483
+ const date = formatDateTime(logEntry.timestamp);
484
+
485
+ // Build attributes
486
+ const attrs = [
487
+ `date="${escapeXml(date)}"`,
488
+ `category="${escapeXml(logEntry.category)}"`,
489
+ `type="${escapeXml(logEntry.level)}"`,
490
+ `message="${escapeXml(logEntry.message)}"`
491
+ ];
492
+
493
+ // Fault alert fields (triggers CMS dashboard alerts)
494
+ if (logEntry.alertType) {
495
+ attrs.push(`alertType="${escapeXml(logEntry.alertType)}"`);
496
+ }
497
+ if (logEntry.eventType) {
498
+ attrs.push(`eventType="${escapeXml(logEntry.eventType)}"`);
499
+ }
500
+
501
+ return ` <log ${attrs.join(' ')} />`;
502
+ });
503
+
504
+ return `<logs>\n${logElements.join('\n')}\n</logs>`;
505
+ }
506
+
507
+ /**
508
+ * Format Date object as "YYYY-MM-DD HH:MM:SS"
509
+ * @private
510
+ */
511
+ function formatDateTime(date) {
512
+ if (!(date instanceof Date)) {
513
+ date = new Date(date);
514
+ }
515
+
516
+ const year = date.getFullYear();
517
+ const month = String(date.getMonth() + 1).padStart(2, '0');
518
+ const day = String(date.getDate()).padStart(2, '0');
519
+ const hours = String(date.getHours()).padStart(2, '0');
520
+ const minutes = String(date.getMinutes()).padStart(2, '0');
521
+ const seconds = String(date.getSeconds()).padStart(2, '0');
522
+
523
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
524
+ }
525
+
526
+ /**
527
+ * Escape XML special characters
528
+ * @private
529
+ */
530
+ function escapeXml(str) {
531
+ if (typeof str !== 'string') {
532
+ return str;
533
+ }
534
+
535
+ return str
536
+ .replace(/&/g, '&amp;')
537
+ .replace(/</g, '&lt;')
538
+ .replace(/>/g, '&gt;')
539
+ .replace(/"/g, '&quot;')
540
+ .replace(/'/g, '&apos;');
541
+ }