AkurAI Build
Menu

Public repository / c892c7f938287f874e707a87eb4295a02e86247e

AkurAI-Platform / frontend/js/monitor-client.js

Latest change 018d4689476e473e9b0c9d5fa20c3cb89f1f7f58 - fix: use Build-shared path for Framework binary in pipeline by Ólafur Búi Ólafsson

// Versioned Monitor log contract shared by browser and server shippers.
export const LEVELS = Object.freeze({ trace: 0, debug: 1, info: 2, warn: 3, error: 4 });
export const MAX_LINE_LENGTH = 8192;
export const BATCH_MAX = 128;
export const FLUSH_MS = 2000;

export function normalizeLevel(level) {
  return LEVELS[level] === undefined ? 'info' : level;
}

export function truncateLine(value) {
  return String(value).slice(0, MAX_LINE_LENGTH);
}
// monitor-client.js — browser log shipper for AkurAI-Monitor.
//
// Captures console.warn/error (optionally debug/info), window errors and
// unhandled rejections, batches them, and POSTs to the app's OWN backend
// proxy (default `/api/client-logs`). The Monitor ingest token is NEVER put
// in the browser; the backend proxy attaches it server-side and forwards to
// Monitor `/api/ingest` with origin=client. See monitor-server.js.
//
// Usage (in the app entry):
//   import { initClientLogging } from 'akurai-log/js/monitor-client.js';
//   initClientLogging({ app: 'akurai-notes' });




export function initClientLogging(options = {}) {
  const {
    app = 'app',
    endpoint = '/api/client-logs',
    level = 'debug',
    capture = ['warn', 'error'],
    batchMax = BATCH_MAX,
    flushMs = FLUSH_MS,
  } = options;

  const minRank = LEVELS[level] ?? 1;
  const source = `${app}-client`;
  let queue = [];
  let timer = null;

  function enqueue(lvl, message, fields) {
    if ((LEVELS[lvl] ?? 1) < minRank) return;
    queue.push({
      source,
      line: truncateLine(message),
      level: lvl,
      target: (fields && fields.target) || location.pathname,
      origin: 'client',
      fields: fields || null,
      ts: Math.floor(Date.now() / 1000),
    });
    if (queue.length >= batchMax) flush();
    else if (!timer) timer = setTimeout(flush, flushMs);
  }

  function flush(useBeacon = false) {
    if (timer) {
      clearTimeout(timer);
      timer = null;
    }
    if (queue.length === 0) return;
    const body = JSON.stringify({ logs: queue });
    queue = [];
    try {
      if (useBeacon && navigator.sendBeacon) {
        navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }));
      } else {
        fetch(endpoint, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body,
          keepalive: true,
        }).catch(() => {});
      }
    } catch {
      /* dropping client logs must never break the page */
    }
  }

  // Wrap selected console methods without swallowing their normal behaviour.
  for (const method of capture) {
    if (!(method in console)) continue;
    const original = console[method].bind(console);
    console[method] = (...args) => {
      original(...args);
      enqueue(method, args.map(stringify).join(' '), null);
    };
  }

  window.addEventListener('error', (e) => {
    enqueue('error', e.message || 'window error', {
      target: e.filename || location.pathname,
      line: e.lineno,
      col: e.colno,
    });
  });
  window.addEventListener('unhandledrejection', (e) => {
    enqueue('error', `unhandledrejection: ${stringify(e.reason)}`, null);
  });
  window.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') flush(true);
  });

  return { flush, log: enqueue };
}

function stringify(v) {
  if (typeof v === 'string') return v;
  if (v instanceof Error) return `${v.name}: ${v.message}`;
  try {
    return JSON.stringify(v);
  } catch {
    return String(v);
  }
}