@xiboplayer/stats 0.3.6 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/stats",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Proof of play tracking, stats reporting, and CMS logging",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -9,7 +9,7 @@
9
9
  "./collector": "./src/stats-collector.js"
10
10
  },
11
11
  "dependencies": {
12
- "@xiboplayer/utils": "0.3.6"
12
+ "@xiboplayer/utils": "0.4.0"
13
13
  },
14
14
  "devDependencies": {
15
15
  "vitest": "^2.0.0",
package/src/index.js CHANGED
@@ -12,4 +12,4 @@ export { StatsCollector, formatStats } from './stats-collector.js';
12
12
  * Log reporter for CMS logging
13
13
  * @module @xiboplayer/stats/logger
14
14
  */
15
- export { LogReporter, formatLogs } from './log-reporter.js';
15
+ export { LogReporter, formatLogs, formatFaults } from './log-reporter.js';
@@ -171,7 +171,7 @@ export class LogReporter {
171
171
 
172
172
  this._reportedFaults.set(code, Date.now());
173
173
 
174
- await this.log('error', reason, 'PLAYER', {
174
+ await this.log('error', reason, 'event', {
175
175
  alertType: 'Player Fault',
176
176
  eventType: code
177
177
  });
@@ -179,6 +179,46 @@ export class LogReporter {
179
179
  log.info(`Fault reported: ${code} - ${reason}`);
180
180
  }
181
181
 
182
+ /**
183
+ * Get unsubmitted fault entries for dedicated fault submission.
184
+ * Returns log entries that have alertType='Player Fault' and submitted=0.
185
+ * These are the high-priority entries that should be submitted faster
186
+ * than the normal log collection cycle.
187
+ *
188
+ * @param {number} [limit=10] - Maximum faults to return per batch
189
+ * @returns {Promise<Array>} Array of fault log objects
190
+ */
191
+ async getFaultsForSubmission(limit = 10) {
192
+ if (!this.db) return [];
193
+
194
+ return new Promise((resolve, reject) => {
195
+ const transaction = this.db.transaction([LOGS_STORE], 'readonly');
196
+ const store = transaction.objectStore(LOGS_STORE);
197
+ const index = store.index('submitted');
198
+
199
+ const request = index.openCursor(IDBKeyRange.only(0));
200
+ const faults = [];
201
+
202
+ request.onsuccess = (event) => {
203
+ const cursor = event.target.result;
204
+
205
+ if (cursor && faults.length < limit) {
206
+ if (cursor.value.alertType === 'Player Fault') {
207
+ faults.push(cursor.value);
208
+ }
209
+ cursor.continue();
210
+ } else {
211
+ resolve(faults);
212
+ }
213
+ };
214
+
215
+ request.onerror = () => {
216
+ log.error('Failed to retrieve faults:', request.error);
217
+ reject(new Error(`Failed to retrieve faults: ${request.error}`));
218
+ };
219
+ });
220
+ }
221
+
182
222
  /**
183
223
  * Log an error message
184
224
  *
@@ -234,13 +274,12 @@ export class LogReporter {
234
274
  /**
235
275
  * Get logs ready for submission to CMS
236
276
  *
237
- * Returns unsubmitted logs up to the specified limit.
238
- * Logs are ordered by ID (oldest first).
277
+ * Returns unsubmitted logs up to the spec limit of 50 per batch.
239
278
  *
240
- * @param {number} limit - Maximum number of logs to return (default: 100)
279
+ * @param {number} [limit=50] - Maximum number of logs to return (spec max: 50)
241
280
  * @returns {Promise<Array>} Array of log objects
242
281
  */
243
- async getLogsForSubmission(limit = 100) {
282
+ async getLogsForSubmission(limit = 50) {
244
283
  if (!this.db) {
245
284
  log.warn('Logs database not initialized');
246
285
  return [];
@@ -262,7 +301,7 @@ export class LogReporter {
262
301
  logs.push(cursor.value);
263
302
  cursor.continue();
264
303
  } else {
265
- log.debug(`Retrieved ${logs.length} unsubmitted logs`);
304
+ log.debug(`Retrieved ${logs.length} unsubmitted logs (limit: ${limit})`);
266
305
  resolve(logs);
267
306
  }
268
307
  };
@@ -274,6 +313,25 @@ export class LogReporter {
274
313
  });
275
314
  }
276
315
 
316
+ /**
317
+ * Count unsubmitted logs in the database.
318
+ * @returns {Promise<number>}
319
+ */
320
+ async _countUnsubmitted() {
321
+ return new Promise((resolve) => {
322
+ try {
323
+ const transaction = this.db.transaction([LOGS_STORE], 'readonly');
324
+ const store = transaction.objectStore(LOGS_STORE);
325
+ const index = store.index('submitted');
326
+ const request = index.count(IDBKeyRange.only(0));
327
+ request.onsuccess = () => resolve(request.result);
328
+ request.onerror = () => resolve(0);
329
+ } catch (_) {
330
+ resolve(0);
331
+ }
332
+ });
333
+ }
334
+
277
335
  /**
278
336
  * Clear submitted logs from database
279
337
  *
@@ -457,11 +515,15 @@ export class LogReporter {
457
515
  *
458
516
  * Converts array of log objects to XML format expected by CMS.
459
517
  *
460
- * XML format:
518
+ * XML format (spec-compliant):
461
519
  * ```xml
462
520
  * <logs>
463
- * <log date="2026-02-10 12:00:00" category="PLAYER" type="error"
464
- * message="Failed to load layout 123" />
521
+ * <log date="2026-02-10 12:00:00" category="error">
522
+ * <thread>main</thread>
523
+ * <method>collect</method>
524
+ * <message>Failed to load layout 123</message>
525
+ * <scheduleID>0</scheduleID>
526
+ * </log>
465
527
  * </logs>
466
528
  * ```
467
529
  *
@@ -482,12 +544,14 @@ export function formatLogs(logs) {
482
544
  // Format date as "YYYY-MM-DD HH:MM:SS"
483
545
  const date = formatDateTime(logEntry.timestamp);
484
546
 
485
- // Build attributes
547
+ // Spec categories: only "error" and "audit" are valid
548
+ const category = (logEntry.level === 'error' || logEntry.level === 'audit')
549
+ ? logEntry.level : 'audit';
550
+
551
+ // Build attributes on <log> element
486
552
  const attrs = [
487
553
  `date="${escapeXml(date)}"`,
488
- `category="${escapeXml(logEntry.category)}"`,
489
- `type="${escapeXml(logEntry.level)}"`,
490
- `message="${escapeXml(logEntry.message)}"`
554
+ `category="${escapeXml(category)}"`
491
555
  ];
492
556
 
493
557
  // Fault alert fields (triggers CMS dashboard alerts)
@@ -498,12 +562,45 @@ export function formatLogs(logs) {
498
562
  attrs.push(`eventType="${escapeXml(logEntry.eventType)}"`);
499
563
  }
500
564
 
501
- return ` <log ${attrs.join(' ')} />`;
565
+ // Build child elements (spec format: thread, method, message, scheduleID)
566
+ const thread = escapeXml(logEntry.thread || 'main');
567
+ const method = escapeXml(logEntry.method || logEntry.category || 'PLAYER');
568
+ const message = escapeXml(logEntry.message);
569
+ const scheduleId = escapeXml(String(logEntry.scheduleId || '0'));
570
+
571
+ return ` <log ${attrs.join(' ')}>\n <thread>${thread}</thread>\n <method>${method}</method>\n <message>${message}</message>\n <scheduleID>${scheduleId}</scheduleID>\n </log>`;
502
572
  });
503
573
 
504
574
  return `<logs>\n${logElements.join('\n')}\n</logs>`;
505
575
  }
506
576
 
577
+ /**
578
+ * Format fault log entries as JSON for XMDS ReportFaults submission.
579
+ *
580
+ * Converts fault log objects (from getFaultsForSubmission) into the JSON
581
+ * string format expected by xmds.reportFaults().
582
+ *
583
+ * @param {Array} faults - Array of fault log objects from getFaultsForSubmission()
584
+ * @returns {string} JSON string for XMDS ReportFaults
585
+ *
586
+ * @example
587
+ * const faults = await reporter.getFaultsForSubmission();
588
+ * if (faults.length > 0) {
589
+ * const json = formatFaults(faults);
590
+ * await xmds.reportFaults(json);
591
+ * }
592
+ */
593
+ export function formatFaults(faults) {
594
+ if (!faults || faults.length === 0) return '[]';
595
+
596
+ return JSON.stringify(faults.map(f => ({
597
+ code: f.eventType || 'UNKNOWN',
598
+ reason: f.message || '',
599
+ date: formatDateTime(f.timestamp),
600
+ layoutId: f.scheduleId || 0
601
+ })));
602
+ }
603
+
507
604
  /**
508
605
  * Format Date object as "YYYY-MM-DD HH:MM:SS"
509
606
  * @private
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
- import { LogReporter, formatLogs } from './log-reporter.js';
6
+ import { LogReporter, formatLogs, formatFaults } from './log-reporter.js';
7
7
 
8
8
  describe('LogReporter', () => {
9
9
  let reporter;
@@ -286,6 +286,56 @@ describe('LogReporter', () => {
286
286
  });
287
287
  });
288
288
 
289
+ describe('getFaultsForSubmission', () => {
290
+ it('should return only fault entries (not regular logs)', async () => {
291
+ await reporter.info('Normal log', 'PLAYER');
292
+ await reporter.error('Regular error', 'PLAYER');
293
+ await reporter.reportFault('LAYOUT_FAIL', 'Layout failed');
294
+ await reporter.reportFault('MEDIA_FAIL', 'Media failed');
295
+
296
+ const faults = await reporter.getFaultsForSubmission();
297
+ expect(faults.length).toBe(2);
298
+ expect(faults.every(f => f.alertType === 'Player Fault')).toBe(true);
299
+ });
300
+
301
+ it('should respect limit parameter', async () => {
302
+ await reporter.reportFault('FAULT_1', 'Fault 1', 1);
303
+ await new Promise(r => setTimeout(r, 5));
304
+ await reporter.reportFault('FAULT_2', 'Fault 2', 1);
305
+ await new Promise(r => setTimeout(r, 5));
306
+ await reporter.reportFault('FAULT_3', 'Fault 3', 1);
307
+
308
+ const faults = await reporter.getFaultsForSubmission(2);
309
+ expect(faults.length).toBe(2);
310
+ });
311
+
312
+ it('should return empty array when no faults exist', async () => {
313
+ await reporter.info('Normal log', 'PLAYER');
314
+ await reporter.error('Regular error', 'PLAYER');
315
+
316
+ const faults = await reporter.getFaultsForSubmission();
317
+ expect(faults).toEqual([]);
318
+ });
319
+
320
+ it('should not return faults that have been cleared', async () => {
321
+ await reporter.reportFault('TEST_FAULT', 'Test');
322
+
323
+ const faults = await reporter.getFaultsForSubmission();
324
+ expect(faults.length).toBe(1);
325
+
326
+ await reporter.clearSubmittedLogs(faults);
327
+
328
+ const remaining = await reporter.getFaultsForSubmission();
329
+ expect(remaining).toEqual([]);
330
+ });
331
+
332
+ it('should return empty array when db is not initialized', async () => {
333
+ const r = new LogReporter();
334
+ const faults = await r.getFaultsForSubmission();
335
+ expect(faults).toEqual([]);
336
+ });
337
+ });
338
+
289
339
  describe('database operations', () => {
290
340
  it('should get all logs', async () => {
291
341
  await reporter.error('Error 1', 'PLAYER');
@@ -333,9 +383,11 @@ describe('formatLogs', () => {
333
383
  const xml = formatLogs(logs);
334
384
  expect(xml).toContain('<logs>');
335
385
  expect(xml).toContain('</logs>');
336
- expect(xml).toContain('type="error"');
337
- expect(xml).toContain('message="Test error"');
338
- expect(xml).toContain('category="PLAYER"');
386
+ expect(xml).toContain('category="error"');
387
+ expect(xml).toContain('<message>Test error</message>');
388
+ expect(xml).toContain('<method>PLAYER</method>');
389
+ expect(xml).toContain('<thread>main</thread>');
390
+ expect(xml).toContain('<scheduleID>0</scheduleID>');
339
391
  expect(xml).toContain('date=');
340
392
  });
341
393
 
@@ -375,7 +427,7 @@ describe('formatLogs', () => {
375
427
  expect(xml).toContain('&apos;');
376
428
  });
377
429
 
378
- it('should escape XML special characters in category', () => {
430
+ it('should escape XML special characters in method', () => {
379
431
  const logs = [{
380
432
  level: 'error',
381
433
  message: 'Test',
@@ -384,7 +436,8 @@ describe('formatLogs', () => {
384
436
  }];
385
437
 
386
438
  const xml = formatLogs(logs);
387
- expect(xml).toContain('category="PLAYER&lt;&gt;"');
439
+ // category field from log entry becomes <method> child element
440
+ expect(xml).toContain('<method>PLAYER&lt;&gt;</method>');
388
441
  });
389
442
 
390
443
  it('should format dates correctly', () => {
@@ -400,7 +453,7 @@ describe('formatLogs', () => {
400
453
  expect(xml).toMatch(/date="\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"/);
401
454
  });
402
455
 
403
- it('should handle all log levels', () => {
456
+ it('should map log levels to spec categories (error/audit only)', () => {
404
457
  const logs = [
405
458
  { level: 'error', message: 'Error', category: 'PLAYER', timestamp: new Date() },
406
459
  { level: 'audit', message: 'Audit', category: 'PLAYER', timestamp: new Date() },
@@ -409,10 +462,12 @@ describe('formatLogs', () => {
409
462
  ];
410
463
 
411
464
  const xml = formatLogs(logs);
412
- expect(xml).toContain('type="error"');
413
- expect(xml).toContain('type="audit"');
414
- expect(xml).toContain('type="info"');
415
- expect(xml).toContain('type="debug"');
465
+ // Spec only allows "error" and "audit" as categories
466
+ expect(xml).toContain('category="error"');
467
+ expect(xml).toContain('category="audit"');
468
+ // info and debug should be mapped to "audit"
469
+ const auditCount = (xml.match(/category="audit"/g) || []).length;
470
+ expect(auditCount).toBe(3); // audit + info + debug
416
471
  });
417
472
 
418
473
  it('should include alertType and eventType in XML output for faults', () => {
@@ -481,4 +536,85 @@ describe('formatLogs', () => {
481
536
  const xml = formatLogs(logs);
482
537
  expect(xml).toContain(longMessage);
483
538
  });
539
+
540
+ it('should use custom thread, method, and scheduleId when provided', () => {
541
+ const logs = [{
542
+ level: 'error',
543
+ message: 'Widget failed',
544
+ category: 'RENDERER',
545
+ thread: 'worker-2',
546
+ method: 'renderWidget',
547
+ scheduleId: 42,
548
+ timestamp: new Date('2026-02-10T12:00:00Z')
549
+ }];
550
+
551
+ const xml = formatLogs(logs);
552
+ expect(xml).toContain('<thread>worker-2</thread>');
553
+ expect(xml).toContain('<method>renderWidget</method>');
554
+ expect(xml).toContain('<scheduleID>42</scheduleID>');
555
+ });
556
+
557
+ it('should produce spec-compliant XML structure with child elements', () => {
558
+ const logs = [{
559
+ level: 'error',
560
+ message: 'Test',
561
+ category: 'PLAYER',
562
+ timestamp: new Date('2026-02-10T12:00:00Z')
563
+ }];
564
+
565
+ const xml = formatLogs(logs);
566
+ // Should NOT have message as attribute (old format)
567
+ expect(xml).not.toMatch(/message="/);
568
+ // Should have message as child element (spec format)
569
+ expect(xml).toMatch(/<message>Test<\/message>/);
570
+ // Should have closing </log> tag (not self-closing)
571
+ expect(xml).toContain('</log>');
572
+ });
573
+ });
574
+
575
+ describe('formatFaults', () => {
576
+ it('should format empty faults', () => {
577
+ expect(formatFaults([])).toBe('[]');
578
+ expect(formatFaults(null)).toBe('[]');
579
+ });
580
+
581
+ it('should format fault entries as JSON', () => {
582
+ const faults = [{
583
+ eventType: 'LAYOUT_LOAD_FAILED',
584
+ message: 'Failed to load layout 123',
585
+ timestamp: new Date('2026-02-10T12:00:00Z'),
586
+ scheduleId: 5
587
+ }];
588
+
589
+ const json = formatFaults(faults);
590
+ const parsed = JSON.parse(json);
591
+ expect(parsed).toHaveLength(1);
592
+ expect(parsed[0].code).toBe('LAYOUT_LOAD_FAILED');
593
+ expect(parsed[0].reason).toBe('Failed to load layout 123');
594
+ expect(parsed[0].date).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
595
+ expect(parsed[0].layoutId).toBe(5);
596
+ });
597
+
598
+ it('should handle missing fields with defaults', () => {
599
+ const faults = [{ timestamp: new Date() }];
600
+
601
+ const json = formatFaults(faults);
602
+ const parsed = JSON.parse(json);
603
+ expect(parsed[0].code).toBe('UNKNOWN');
604
+ expect(parsed[0].reason).toBe('');
605
+ expect(parsed[0].layoutId).toBe(0);
606
+ });
607
+
608
+ it('should format multiple faults', () => {
609
+ const faults = [
610
+ { eventType: 'FAULT_1', message: 'First', timestamp: new Date(), scheduleId: 1 },
611
+ { eventType: 'FAULT_2', message: 'Second', timestamp: new Date(), scheduleId: 2 }
612
+ ];
613
+
614
+ const json = formatFaults(faults);
615
+ const parsed = JSON.parse(json);
616
+ expect(parsed).toHaveLength(2);
617
+ expect(parsed[0].code).toBe('FAULT_1');
618
+ expect(parsed[1].code).toBe('FAULT_2');
619
+ });
484
620
  });
@@ -109,14 +109,22 @@ export class StatsCollector {
109
109
  *
110
110
  * @param {number} layoutId - Layout ID from CMS
111
111
  * @param {number} scheduleId - Schedule ID that triggered this layout
112
+ * @param {Object} [options] - Options
113
+ * @param {boolean} [options.enableStat=true] - Whether stats are enabled for this layout
112
114
  * @returns {Promise<void>}
113
115
  */
114
- async startLayout(layoutId, scheduleId) {
116
+ async startLayout(layoutId, scheduleId, options) {
115
117
  if (!this.db) {
116
118
  log.warn('Stats database not initialized');
117
119
  return;
118
120
  }
119
121
 
122
+ // Respect enableStat flag from XLF (layout/widget level stat suppression)
123
+ if (options?.enableStat === false) {
124
+ log.debug(`Stats disabled for layout ${layoutId} (enableStat=false)`);
125
+ return;
126
+ }
127
+
120
128
  // Key excludes scheduleId: only one layout instance can be in-progress at a time,
121
129
  // and scheduleId may change mid-play when a collection cycle completes.
122
130
  const key = `layout-${layoutId}`;
@@ -126,7 +134,7 @@ export class StatsCollector {
126
134
  const prev = this.inProgressStats.get(key);
127
135
  prev.end = new Date();
128
136
  prev.duration = Math.floor((prev.end - prev.start) / 1000);
129
- await this._saveStat(prev);
137
+ await this._saveStatSplit(prev);
130
138
  this.inProgressStats.delete(key);
131
139
  log.debug(`Layout ${layoutId} replay - ended previous cycle (${prev.duration}s)`);
132
140
  }
@@ -174,9 +182,9 @@ export class StatsCollector {
174
182
  stat.end = new Date();
175
183
  stat.duration = Math.floor((stat.end - stat.start) / 1000);
176
184
 
177
- // Save to database
185
+ // Save to database (splitting at hour boundaries for CMS aggregation)
178
186
  try {
179
- await this._saveStat(stat);
187
+ await this._saveStatSplit(stat);
180
188
  this.inProgressStats.delete(key);
181
189
  log.debug(`Ended tracking layout ${layoutId} (${stat.duration}s)`);
182
190
  } catch (error) {
@@ -195,14 +203,23 @@ export class StatsCollector {
195
203
  * @param {number} mediaId - Media ID from CMS
196
204
  * @param {number} layoutId - Parent layout ID
197
205
  * @param {number} scheduleId - Schedule ID
206
+ * @param {string|number} [widgetId] - Widget ID (for non-library widgets with no mediaId)
207
+ * @param {Object} [options] - Options
208
+ * @param {boolean} [options.enableStat=true] - Whether stats are enabled for this widget
198
209
  * @returns {Promise<void>}
199
210
  */
200
- async startWidget(mediaId, layoutId, scheduleId) {
211
+ async startWidget(mediaId, layoutId, scheduleId, widgetId, options) {
201
212
  if (!this.db) {
202
213
  log.warn('Stats database not initialized');
203
214
  return;
204
215
  }
205
216
 
217
+ // Respect enableStat flag from XLF (layout/widget level stat suppression)
218
+ if (options?.enableStat === false) {
219
+ log.debug(`Stats disabled for widget ${mediaId} (enableStat=false)`);
220
+ return;
221
+ }
222
+
206
223
  // Key excludes scheduleId: it may change mid-play during collection cycles.
207
224
  const key = `media-${mediaId}-${layoutId}`;
208
225
 
@@ -211,7 +228,7 @@ export class StatsCollector {
211
228
  const prev = this.inProgressStats.get(key);
212
229
  prev.end = new Date();
213
230
  prev.duration = Math.floor((prev.end - prev.start) / 1000);
214
- await this._saveStat(prev);
231
+ await this._saveStatSplit(prev);
215
232
  this.inProgressStats.delete(key);
216
233
  log.debug(`Widget ${mediaId} replay - ended previous cycle (${prev.duration}s)`);
217
234
  }
@@ -219,6 +236,7 @@ export class StatsCollector {
219
236
  const stat = {
220
237
  type: 'media',
221
238
  mediaId,
239
+ widgetId: widgetId || null,
222
240
  layoutId,
223
241
  scheduleId,
224
242
  start: new Date(),
@@ -261,9 +279,9 @@ export class StatsCollector {
261
279
  stat.end = new Date();
262
280
  stat.duration = Math.floor((stat.end - stat.start) / 1000);
263
281
 
264
- // Save to database
282
+ // Save to database (splitting at hour boundaries for CMS aggregation)
265
283
  try {
266
- await this._saveStat(stat);
284
+ await this._saveStatSplit(stat);
267
285
  this.inProgressStats.delete(key);
268
286
  log.debug(`Ended tracking widget ${mediaId} (${stat.duration}s)`);
269
287
  } catch (error) {
@@ -272,6 +290,48 @@ export class StatsCollector {
272
290
  }
273
291
  }
274
292
 
293
+ /**
294
+ * Record an event stat (point-in-time engagement data)
295
+ *
296
+ * Creates an instant stat entry with no duration. Used for tracking
297
+ * interactive touches, webhook triggers, and other engagement events.
298
+ * Unlike layout/widget stats, events have no start/end cycle.
299
+ *
300
+ * @param {string} tag - Event tag describing the interaction (e.g. 'touch', 'webhook')
301
+ * @param {number} layoutId - Layout ID where the event occurred
302
+ * @param {number} widgetId - Widget ID that triggered the event
303
+ * @param {number} scheduleId - Schedule ID for the current schedule
304
+ * @returns {Promise<void>}
305
+ */
306
+ async recordEvent(tag, layoutId, widgetId, scheduleId) {
307
+ if (!this.db) {
308
+ log.warn('Stats database not initialized');
309
+ return;
310
+ }
311
+
312
+ const now = new Date();
313
+ const stat = {
314
+ type: 'event',
315
+ tag,
316
+ layoutId,
317
+ widgetId,
318
+ scheduleId,
319
+ start: now,
320
+ end: now,
321
+ duration: 0,
322
+ count: 1,
323
+ submitted: 0
324
+ };
325
+
326
+ try {
327
+ await this._saveStat(stat);
328
+ log.debug(`Recorded event '${tag}' for widget ${widgetId} in layout ${layoutId}`);
329
+ } catch (error) {
330
+ log.error(`Failed to record event '${tag}':`, error);
331
+ throw error;
332
+ }
333
+ }
334
+
275
335
  /**
276
336
  * Get stats ready for submission to CMS
277
337
  *
@@ -382,7 +442,7 @@ export class StatsCollector {
382
442
  const hour = stat.start instanceof Date
383
443
  ? stat.start.toISOString().slice(0, 13)
384
444
  : new Date(stat.start).toISOString().slice(0, 13);
385
- const key = `${stat.type}|${stat.layoutId}|${stat.mediaId || ''}|${stat.scheduleId}|${hour}`;
445
+ const key = `${stat.type}|${stat.layoutId}|${stat.mediaId || ''}|${stat.widgetId || ''}|${stat.tag || ''}|${stat.scheduleId}|${hour}`;
386
446
 
387
447
  if (groups.has(key)) {
388
448
  const group = groups.get(key);
@@ -495,6 +555,65 @@ export class StatsCollector {
495
555
  });
496
556
  }
497
557
 
558
+ /**
559
+ * Split a stat record at hour boundaries.
560
+ * If a stat spans multiple hours (e.g. 12:50→13:10), it is split into
561
+ * separate records at each hour boundary for correct CMS aggregation.
562
+ * Returns an array of one or more stat objects.
563
+ * @param {Object} stat - Finalized stat with start, end, duration
564
+ * @returns {Object[]}
565
+ * @private
566
+ */
567
+ _splitAtHourBoundaries(stat) {
568
+ const start = stat.start;
569
+ const end = stat.end;
570
+
571
+ // No split needed if start and end are in the same hour
572
+ if (start.getFullYear() === end.getFullYear() &&
573
+ start.getMonth() === end.getMonth() &&
574
+ start.getDate() === end.getDate() &&
575
+ start.getHours() === end.getHours()) {
576
+ return [stat];
577
+ }
578
+
579
+ const results = [];
580
+ let segStart = new Date(start.getTime());
581
+
582
+ while (segStart < end) {
583
+ // Next hour boundary: top of the next hour from segStart
584
+ const nextHour = new Date(segStart.getTime());
585
+ nextHour.setMinutes(0, 0, 0);
586
+ nextHour.setHours(nextHour.getHours() + 1);
587
+
588
+ const segEnd = nextHour < end ? nextHour : end;
589
+ const duration = Math.floor((segEnd - segStart) / 1000);
590
+
591
+ results.push({
592
+ ...stat,
593
+ start: new Date(segStart.getTime()),
594
+ end: new Date(segEnd.getTime()),
595
+ duration,
596
+ count: 1
597
+ });
598
+
599
+ segStart = segEnd;
600
+ }
601
+
602
+ return results;
603
+ }
604
+
605
+ /**
606
+ * Save a stat to IndexedDB, splitting at hour boundaries first.
607
+ * @param {Object} stat - Finalized stat with start, end, duration
608
+ * @private
609
+ */
610
+ async _saveStatSplit(stat) {
611
+ const parts = this._splitAtHourBoundaries(stat);
612
+ for (const part of parts) {
613
+ await this._saveStat(part);
614
+ }
615
+ }
616
+
498
617
  /**
499
618
  * Clean old stats when quota is exceeded
500
619
  * Deletes oldest 100 submitted stats
@@ -581,9 +700,25 @@ export function formatStats(stats) {
581
700
  `layoutid="${stat.layoutId}"`,
582
701
  ];
583
702
 
584
- // Add mediaId for media stats
585
- if (stat.type === 'media' && stat.mediaId) {
586
- attrs.push(`mediaid="${stat.mediaId}"`);
703
+ // Add mediaId and widgetId for media/widget stats
704
+ if (stat.type === 'media') {
705
+ if (stat.mediaId) {
706
+ attrs.push(`mediaid="${stat.mediaId}"`);
707
+ }
708
+ // Include widgetId for non-library widgets (native widgets have no mediaId)
709
+ if (stat.widgetId) {
710
+ attrs.push(`widgetid="${stat.widgetId}"`);
711
+ }
712
+ }
713
+
714
+ // Add tag and widgetId for event stats
715
+ if (stat.type === 'event') {
716
+ if (stat.tag) {
717
+ attrs.push(`tag="${escapeXml(stat.tag)}"`);
718
+ }
719
+ if (stat.widgetId) {
720
+ attrs.push(`widgetid="${stat.widgetId}"`);
721
+ }
587
722
  }
588
723
 
589
724
  // Add count and duration
@@ -189,6 +189,70 @@ describe('StatsCollector', () => {
189
189
  });
190
190
  });
191
191
 
192
+ describe('event tracking', () => {
193
+ it('should record an event stat', async () => {
194
+ await collector.recordEvent('touch', 123, 456, 789);
195
+
196
+ const stats = await collector.getAllStats();
197
+ expect(stats.length).toBe(1);
198
+ expect(stats[0].type).toBe('event');
199
+ expect(stats[0].tag).toBe('touch');
200
+ expect(stats[0].layoutId).toBe(123);
201
+ expect(stats[0].widgetId).toBe(456);
202
+ expect(stats[0].scheduleId).toBe(789);
203
+ expect(stats[0].duration).toBe(0);
204
+ expect(stats[0].count).toBe(1);
205
+ expect(stats[0].submitted).toBe(0);
206
+ expect(stats[0].start).toBeInstanceOf(Date);
207
+ expect(stats[0].end).toBeInstanceOf(Date);
208
+ });
209
+
210
+ it('should record multiple events', async () => {
211
+ await collector.recordEvent('touch', 123, 456, 789);
212
+ await collector.recordEvent('webhook', 123, 456, 789);
213
+ await collector.recordEvent('touch', 123, 457, 789);
214
+
215
+ const stats = await collector.getAllStats();
216
+ expect(stats.length).toBe(3);
217
+ expect(stats.every(s => s.type === 'event')).toBe(true);
218
+ });
219
+
220
+ it('should not store event in inProgressStats', async () => {
221
+ await collector.recordEvent('touch', 123, 456, 789);
222
+
223
+ expect(collector.inProgressStats.size).toBe(0);
224
+ });
225
+
226
+ it('should be retrievable for submission', async () => {
227
+ await collector.recordEvent('touch', 123, 456, 789);
228
+
229
+ const stats = await collector.getStatsForSubmission();
230
+ expect(stats.length).toBe(1);
231
+ expect(stats[0].type).toBe('event');
232
+ expect(stats[0].tag).toBe('touch');
233
+ });
234
+
235
+ it('should handle missing db gracefully', async () => {
236
+ const c = new StatsCollector();
237
+ // Should not throw
238
+ await c.recordEvent('touch', 123, 456, 789);
239
+ });
240
+
241
+ it('should coexist with layout and widget stats', async () => {
242
+ await collector.startLayout(123, 789);
243
+ await collector.endLayout(123, 789);
244
+ await collector.startWidget(111, 123, 789);
245
+ await collector.endWidget(111, 123, 789);
246
+ await collector.recordEvent('touch', 123, 456, 789);
247
+
248
+ const stats = await collector.getAllStats();
249
+ expect(stats.length).toBe(3);
250
+ expect(stats.some(s => s.type === 'layout')).toBe(true);
251
+ expect(stats.some(s => s.type === 'media')).toBe(true);
252
+ expect(stats.some(s => s.type === 'event')).toBe(true);
253
+ });
254
+ });
255
+
192
256
  describe('stats submission flow', () => {
193
257
  it('should get unsubmitted stats', async () => {
194
258
  // Create some stats
@@ -442,6 +506,45 @@ describe('formatStats', () => {
442
506
  expect(xml).toMatch(/todt="\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"/);
443
507
  });
444
508
 
509
+ it('should format event stat', () => {
510
+ const stats = [{
511
+ type: 'event',
512
+ tag: 'touch',
513
+ layoutId: 123,
514
+ widgetId: 456,
515
+ scheduleId: 789,
516
+ start: new Date('2026-02-10T12:00:00Z'),
517
+ end: new Date('2026-02-10T12:00:00Z'),
518
+ duration: 0,
519
+ count: 1
520
+ }];
521
+
522
+ const xml = formatStats(stats);
523
+ expect(xml).toContain('type="event"');
524
+ expect(xml).toContain('tag="touch"');
525
+ expect(xml).toContain('widgetid="456"');
526
+ expect(xml).toContain('layoutid="123"');
527
+ expect(xml).toContain('scheduleid="789"');
528
+ expect(xml).toContain('duration="0"');
529
+ });
530
+
531
+ it('should escape XML in event tag', () => {
532
+ const stats = [{
533
+ type: 'event',
534
+ tag: 'touch&click<script>',
535
+ layoutId: 123,
536
+ widgetId: 456,
537
+ scheduleId: 789,
538
+ start: new Date('2026-02-10T12:00:00Z'),
539
+ end: new Date('2026-02-10T12:00:00Z'),
540
+ duration: 0,
541
+ count: 1
542
+ }];
543
+
544
+ const xml = formatStats(stats);
545
+ expect(xml).toContain('tag="touch&amp;click&lt;script&gt;"');
546
+ });
547
+
445
548
  it('should handle missing end date', () => {
446
549
  const stats = [{
447
550
  type: 'layout',
@@ -458,4 +561,129 @@ describe('formatStats', () => {
458
561
  expect(xml).toContain('fromdt=');
459
562
  expect(xml).toContain('todt=');
460
563
  });
564
+
565
+ it('should include widgetId for media stats with no mediaId (native widgets)', () => {
566
+ const stats = [{
567
+ type: 'media',
568
+ mediaId: null,
569
+ widgetId: 42,
570
+ layoutId: 123,
571
+ scheduleId: 456,
572
+ start: new Date('2026-02-10T12:00:00Z'),
573
+ end: new Date('2026-02-10T12:01:00Z'),
574
+ duration: 60,
575
+ count: 1
576
+ }];
577
+
578
+ const xml = formatStats(stats);
579
+ expect(xml).toContain('type="media"');
580
+ expect(xml).toContain('widgetid="42"');
581
+ expect(xml).not.toContain('mediaid=');
582
+ });
583
+
584
+ it('should include both mediaid and widgetid for library widgets', () => {
585
+ const stats = [{
586
+ type: 'media',
587
+ mediaId: 789,
588
+ widgetId: 42,
589
+ layoutId: 123,
590
+ scheduleId: 456,
591
+ start: new Date('2026-02-10T12:00:00Z'),
592
+ end: new Date('2026-02-10T12:01:00Z'),
593
+ duration: 60,
594
+ count: 1
595
+ }];
596
+
597
+ const xml = formatStats(stats);
598
+ expect(xml).toContain('mediaid="789"');
599
+ expect(xml).toContain('widgetid="42"');
600
+ });
601
+ });
602
+
603
+ describe('_splitAtHourBoundaries', () => {
604
+ let collector;
605
+
606
+ beforeEach(async () => {
607
+ collector = new StatsCollector();
608
+ await collector.init();
609
+ });
610
+
611
+ afterEach(() => {
612
+ if (collector?.db) collector.db.close();
613
+ });
614
+
615
+ it('should not split a stat within the same hour', () => {
616
+ const stat = {
617
+ type: 'layout', layoutId: 1, scheduleId: 1, count: 1, submitted: 0,
618
+ start: new Date('2026-02-10T12:10:00Z'),
619
+ end: new Date('2026-02-10T12:50:00Z'),
620
+ duration: 2400
621
+ };
622
+ const parts = collector._splitAtHourBoundaries(stat);
623
+ expect(parts).toHaveLength(1);
624
+ expect(parts[0].duration).toBe(2400);
625
+ });
626
+
627
+ it('should split a stat spanning two hours', () => {
628
+ const stat = {
629
+ type: 'layout', layoutId: 1, scheduleId: 1, count: 1, submitted: 0,
630
+ start: new Date('2026-02-10T12:50:00Z'),
631
+ end: new Date('2026-02-10T13:10:00Z'),
632
+ duration: 1200
633
+ };
634
+ const parts = collector._splitAtHourBoundaries(stat);
635
+ expect(parts).toHaveLength(2);
636
+ expect(parts[0].duration).toBe(600); // 12:50 → 13:00
637
+ expect(parts[1].duration).toBe(600); // 13:00 → 13:10
638
+ expect(parts[0].end.toISOString()).toBe('2026-02-10T13:00:00.000Z');
639
+ expect(parts[1].start.toISOString()).toBe('2026-02-10T13:00:00.000Z');
640
+ });
641
+
642
+ it('should split a stat spanning three hours', () => {
643
+ const stat = {
644
+ type: 'layout', layoutId: 1, scheduleId: 1, count: 1, submitted: 0,
645
+ start: new Date('2026-02-10T11:30:00Z'),
646
+ end: new Date('2026-02-10T13:15:00Z'),
647
+ duration: 6300
648
+ };
649
+ const parts = collector._splitAtHourBoundaries(stat);
650
+ expect(parts).toHaveLength(3);
651
+ expect(parts[0].duration).toBe(1800); // 11:30 → 12:00
652
+ expect(parts[1].duration).toBe(3600); // 12:00 → 13:00
653
+ expect(parts[2].duration).toBe(900); // 13:00 → 13:15
654
+ // Durations sum to original
655
+ const total = parts.reduce((s, p) => s + p.duration, 0);
656
+ expect(total).toBe(6300);
657
+ });
658
+
659
+ it('should split at day boundary (midnight)', () => {
660
+ const stat = {
661
+ type: 'layout', layoutId: 1, scheduleId: 1, count: 1, submitted: 0,
662
+ start: new Date('2026-02-10T23:50:00Z'),
663
+ end: new Date('2026-02-11T00:10:00Z'),
664
+ duration: 1200
665
+ };
666
+ const parts = collector._splitAtHourBoundaries(stat);
667
+ expect(parts).toHaveLength(2);
668
+ expect(parts[0].duration).toBe(600);
669
+ expect(parts[1].duration).toBe(600);
670
+ });
671
+
672
+ it('should preserve all stat fields in split records', () => {
673
+ const stat = {
674
+ type: 'media', layoutId: 5, mediaId: 42, scheduleId: 7, count: 1, submitted: 0,
675
+ start: new Date('2026-02-10T12:50:00Z'),
676
+ end: new Date('2026-02-10T13:10:00Z'),
677
+ duration: 1200
678
+ };
679
+ const parts = collector._splitAtHourBoundaries(stat);
680
+ for (const part of parts) {
681
+ expect(part.type).toBe('media');
682
+ expect(part.layoutId).toBe(5);
683
+ expect(part.mediaId).toBe(42);
684
+ expect(part.scheduleId).toBe(7);
685
+ expect(part.count).toBe(1);
686
+ expect(part.submitted).toBe(0);
687
+ }
688
+ });
461
689
  });