AkurAI Build
Menu

Public repository / 17a8bd11df4ab26ca2b7214e4a49524fc4ce4d7f

akurai-log / js/monitor-server.js

Latest change 17a8bd11df4ab26ca2b7214e4a49524fc4ce4d7f - akurai-log: structured tracing + JS shippers to AkurAI-Monitor by agent

// monitor-server.js — server-side log shipper for AkurAI-Monitor (Node/Bun).
//
// Zero dependencies: uses global `fetch` (Node >=18, Bun). Provides:
//   - createLogger(): a structured logger that also ships to Monitor
//   - clientProxy(): forwards browser logs (from monitor-client.js) to Monitor
//     with the ingest token attached server-side and origin=client.
//
// Config (env or explicit): MONITOR_INGEST_URL, MONITOR_INGEST_TOKEN,
// MONITOR_HOST_ID, MONITOR_APP_ID, MONITOR_LOG_LEVEL.

const LEVELS = { trace: 0, debug: 1, info: 2, warn: 3, error: 4 };

function resolve(opts = {}) {
  return {
    url: opts.url || process.env.MONITOR_INGEST_URL || '',
    token: opts.token || process.env.MONITOR_INGEST_TOKEN || '',
    hostId: numOrNull(opts.hostId ?? process.env.MONITOR_HOST_ID),
    appId: numOrNull(opts.appId ?? process.env.MONITOR_APP_ID),
    level: opts.level || process.env.MONITOR_LOG_LEVEL || 'debug',
  };
}

function numOrNull(v) {
  if (v === undefined || v === null || v === '') return null;
  const n = Number(v);
  return Number.isFinite(n) ? n : null;
}

async function ship(cfg, logs) {
  if (!cfg.url || !cfg.token || logs.length === 0) return;
  try {
    await fetch(`${cfg.url.replace(/\/$/, '')}/api/ingest`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${cfg.token}`,
      },
      body: JSON.stringify({ logs }),
    });
  } catch {
    /* best-effort; never throw into the caller */
  }
}

// A batching, non-blocking structured logger.
export function createLogger(app, options = {}) {
  const cfg = resolve(options);
  const minRank = LEVELS[cfg.level] ?? 1;
  const source = app;
  let queue = [];
  let timer = null;

  function flush() {
    if (timer) {
      clearTimeout(timer);
      timer = null;
    }
    if (queue.length === 0) return;
    const batch = queue;
    queue = [];
    void ship(cfg, batch);
  }

  function emit(level, message, fields) {
    const line = typeof message === 'string' ? message : JSON.stringify(message);
    // Mirror to stdout/stderr so journald still captures it.
    (level === 'error' || level === 'warn' ? console.error : console.log)(
      `[${level}] ${source}: ${line}`
    );
    if ((LEVELS[level] ?? 1) < minRank) return;
    queue.push({
      source,
      line: String(line).slice(0, 16384),
      level,
      target: (fields && fields.target) || app,
      pid: process.pid,
      origin: 'server',
      fields: fields || null,
      host_id: cfg.hostId,
      application_id: cfg.appId,
      ts: Math.floor(Date.now() / 1000),
    });
    if (queue.length >= 128) flush();
    else if (!timer) timer = setTimeout(flush, 2000);
  }

  return {
    trace: (m, f) => emit('trace', m, f),
    debug: (m, f) => emit('debug', m, f),
    info: (m, f) => emit('info', m, f),
    warn: (m, f) => emit('warn', m, f),
    error: (m, f) => emit('error', m, f),
    flush,
  };
}

// Build a handler that forwards browser-submitted logs to Monitor.
// `body` is the parsed JSON from monitor-client.js: { logs: [...] }.
// Returns { inserted } or throws on malformed input. Framework-agnostic:
// mount it on your own POST /api/client-logs route.
export function clientProxy(app, options = {}) {
  const cfg = resolve(options);
  const source = `${app}-client`;
  return async function handleClientLogs(body) {
    if (!body || !Array.isArray(body.logs)) {
      throw new Error('expected { logs: [...] }');
    }
    const logs = body.logs.slice(0, 500).map((l) => ({
      source,
      line: String(l.line ?? '').slice(0, 16384),
      level: LEVELS[l.level] !== undefined ? l.level : 'info',
      target: typeof l.target === 'string' ? l.target : null,
      origin: 'client',
      fields: l.fields ?? null,
      host_id: cfg.hostId,
      application_id: cfg.appId,
      ts: Number.isFinite(l.ts) ? l.ts : Math.floor(Date.now() / 1000),
    }));
    await ship(cfg, logs);
    return { inserted: logs.length };
  };
}