All files / src/routes logs.js

94.28% Statements 33/35
90% Branches 18/20
100% Functions 3/3
94.28% Lines 33/35

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82              1x   1x 1x 1x 1x     12x   12x 11x 11x     11x 2x 1x       1x     9x       10x 12x 1x       11x 2x   9x 2x     7x 7x       5x 5x   5x 7x             5x               12x 1x 1x     12x     1x  
/**
 * Client log ingestion routes — accepts structured log entries from mobile clients.
 *
 * POST /api/logs       → Submit one or a batch of log entries
 * GET  /api/logs/stats → Return daily quota statistics
 */
 
const log = require('../utils/log');
 
const VALID_LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'];
const MAX_BATCH_SIZE = 50;
const MAX_MESSAGE_LENGTH = 2000;
const MAX_SOURCE_LENGTH = 100;
 
function createLogsRouter(logger) {
  const router = require('express').Router();
  // POST /logs — Accept log entries from clients
  router.post('/logs', async (req, res) => {
    try {
      const { batch } = req.body;
      let entries;
 
      if (Array.isArray(batch)) {
        if (batch.length > MAX_BATCH_SIZE) {
          return res.status(400).json({
            error: `Batch size ${batch.length} exceeds maximum of ${MAX_BATCH_SIZE}`,
          });
        }
        entries = batch;
      } else {
        // Single entry — the body itself is the entry
        entries = [req.body];
      }
 
      // Validate all entries
      for (const entry of entries) {
        if (!entry.level || !VALID_LEVELS.includes(entry.level)) {
          return res.status(400).json({
            error: `Invalid level: ${entry.level}. Must be one of: ${VALID_LEVELS.join(', ')}`,
          });
        }
        if (!entry.source || typeof entry.source !== 'string') {
          return res.status(400).json({ error: 'Missing required field: source' });
        }
        if (!entry.message || typeof entry.message !== 'string') {
          return res.status(400).json({ error: 'Missing required field: message' });
        }
        // Truncate oversized fields to prevent log bloat
        entry.source = entry.source.slice(0, MAX_SOURCE_LENGTH);
        entry.message = entry.message.slice(0, MAX_MESSAGE_LENGTH);
      }
 
      // Enrich and log each entry
      const userId = req.auth?.uid || null;
      const traceId = req.requestTraceId || null;
 
      for (const entry of entries) {
        await logger.log({
          ...entry,
          userId,
          traceId,
        });
      }
 
      res.status(202).json({ accepted: entries.length });
    } catch (err) {
      log.error('logs', 'Error ingesting logs', { error: err.message });
      res.status(500).json({ error: 'Internal server error' });
    }
  });
 
  // GET /logs/stats — Return quota stats
  router.get('/logs/stats', (req, res) => {
    const stats = logger.getDailyStats();
    res.json(stats);
  });
 
  return router;
}
 
module.exports = { createLogsRouter };