AkurAI Build
Menu

Public repository / 8b66e18ce8cb51ca3e4124f89fd1429a1d4792f3

AkurAI-Mail / static/monitor-client.js

Latest change 22202d1bde69be4ace8a299dffd91f8ff24378df - Recover deployed webmail frontend (frontend parity with EC2 a6afffc) by Ólafur Búi Ólafsson

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

export const CORRELATION_FIELDS = Object.freeze([
  'organization_id',
  'workspace_id',
  'installation_id',
  'deployment_id',
  'request_id',
]);

export const PRIVACY_CLASSES = Object.freeze({
  correlation: 'correlation',
  metadata: 'metadata',
  sensitive: 'sensitive',
  prohibited: 'prohibited',
});
const SENSITIVE_KEY_PARTS = ['user_id', 'session_id', 'ip_address'];
const PROHIBITED_KEY_PARTS = [
  'authorization',
  'api_key',
  'apikey',
  'bearer',
  'password',
  'passwd',
  'secret',
  'token',
  'cookie',
  'email',
  'note',
  'content',
  'file',
  'body',
  'prompt',
];

export function normalizeLevel(level) {
  return LEVELS[level] === undefined ? 'info' : level;
}
export function truncateLine(value) {
  return Array.from(redactText(String(value))).slice(0, MAX_LINE_LENGTH).join('');
}

export function classifyField(name) {
  const key = normalizeFieldName(name);
  if (!key) return PRIVACY_CLASSES.prohibited;
  if (CORRELATION_FIELDS.includes(key)) {
    return PRIVACY_CLASSES.correlation;
  }
  if (SENSITIVE_KEY_PARTS.some((part) => key.includes(part))) {
    return PRIVACY_CLASSES.sensitive;
  }
  if (PROHIBITED_KEY_PARTS.some((part) => key.includes(part))) {
    return PRIVACY_CLASSES.prohibited;
  }
  return PRIVACY_CLASSES.metadata;
}

export function normalizeFieldName(name) {
  const raw = String(name);
  if (!/^[\x00-\x7F]+$/.test(raw)) return null;
  const chars = Array.from(raw);
  let normalized = '';
  for (let index = 0; index < chars.length; index += 1) {
    const char = chars[index];
    if (!/[A-Za-z0-9_]/.test(char)) return null;
    if (/[A-Z]/.test(char)) {
      const previous = chars[index - 1];
      const next = chars[index + 1];
      const boundary = index > 0
        && (/[a-z]/.test(previous)
          || /[0-9]/.test(previous)
          || (/[A-Z]/.test(previous) && next !== undefined && /[a-z]/.test(next)));
      if (boundary) normalized += '_';
    }
    normalized += char.toLowerCase();
  }
  return /^[a-z0-9_]+$/.test(normalized) ? normalized : null;
}

export function normalizeCorrelation(value = {}) {
  const input = value && typeof value === 'object' ? value : {};
  const result = {};
  for (const field of CORRELATION_FIELDS) {
    const camel = field.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
    const candidate = input[field] ?? input[camel];
    if (candidate === undefined || candidate === null || candidate === '') continue;
    if (typeof candidate !== 'string') continue;
    const id = candidate.trim();
    if (isOpaqueId(id)) result[field] = id;
  }
  return result;
}

export function mergeCorrelation(base = {}, override = {}) {
  const left = normalizeCorrelation(base);
  const right = normalizeCorrelation(override);
  for (const field of CORRELATION_FIELDS) {
    if (left[field] && right[field] && left[field] !== right[field]) return null;
  }
  return { ...left, ...right };
}

export function sanitizeFields(fields) {
  if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return null;
  const result = {};
  let count = 0;
  for (const name of Object.keys(fields).sort()) {
    const value = fields[name];
    if (count++ >= MAX_FIELDS) break;
    if (classifyField(name) !== PRIVACY_CLASSES.metadata) continue;
    if (typeof value === 'string') {
      result[name] = truncateLine(value).slice(0, MAX_FIELD_LENGTH);
    } else if (typeof value === 'number' && Number.isFinite(value)) {
      result[name] = value;
    } else if (typeof value === 'boolean') {
      result[name] = value;
    }
  }
  return Object.keys(result).length === 0 ? null : result;
}

export function sanitizeLog(input, baseCorrelation = {}) {
  if (!input || typeof input !== 'object') return null;
  const correlation = mergeCorrelation(baseCorrelation, input);
  if (!correlation) return null;
  const fields = sanitizeFields(input.fields);
  const target = truncateLine(typeof input.target === 'string' ? input.target : '');
  return {
    source: typeof input.source === 'string' ? truncateLine(input.source) : '',
    line: truncateLine(input.line ?? ''),
    level: normalizeLevel(input.level),
    target,
    origin: input.origin === 'client' ? 'client' : 'server',
    fields,
    ...Object.fromEntries(CORRELATION_FIELDS.map((field) => [field, correlation[field] ?? null])),
    ts: Number.isFinite(input.ts) ? input.ts : Math.floor(Date.now() / 1000),
  };
}

function isOpaqueId(value) {
  if (value.length === 0 || value.length > MAX_ID_LENGTH) return false;
  for (const char of value) {
    if (!/[A-Za-z0-9_.:-]/.test(char)) return false;
  }
  return true;
}

function percentDecodeOnce(value) {
  const source = new TextEncoder().encode(value);
  const decoded = [];
  const hex = (byte) => {
    if (byte >= 48 && byte <= 57) return byte - 48;
    if (byte >= 65 && byte <= 70) return byte - 55;
    if (byte >= 97 && byte <= 102) return byte - 87;
    return -1;
  };
  for (let index = 0; index < source.length; index += 1) {
    if (source[index] === 37 && index + 2 < source.length) {
      const high = hex(source[index + 1]);
      const low = hex(source[index + 2]);
      if (high >= 0 && low >= 0) {
        decoded.push((high << 4) | low);
        index += 2;
        continue;
      }
    }
    decoded.push(source[index]);
  }
  return new TextDecoder().decode(Uint8Array.from(decoded));
}

function trimAscii(value) {
  return value.replace(/^[\t\n\v\f\r ]+/, '');
}

function trimAsciiQuotes(value) {
  let result = value;
  while (true) {
    const next = trimAscii(result).replace(/^[\"']+|[\"']+$/g, '');
    if (next === result) return next;
    result = next;
  }
}
export function redactText(value) {
  let decoded = String(value);
  const maxRounds = decoded.length + 1;
  for (let i = 0; i < maxRounds; i += 1) {
    const next = percentDecodeOnce(decoded);
    if (next === decoded) break;
    decoded = next;
  }
  const normalized = decoded.replace(/\r/g, ' ').replace(/\n/g, ' ');
  const lower = normalized.toLowerCase();
  if (hasSecretPattern(lower) || lower.includes('-----begin ')) {
    return '[REDACTED_SECRET]';
  }
  if (looksLikeEmail(normalized)) {
    return '[REDACTED_EMAIL]';
  }
  return normalized;
}

function hasSecretPattern(lower) {
  for (const marker of [
    'authorization',
    'api_key',
    'apikey',
    'api-key',
    'password',
    'passwd',
    'secret',
    'token',
    'cookie',
    'bearer',
  ]) {
    let offset = 0;
    while (offset < lower.length) {
      const found = lower.indexOf(marker, offset);
      if (found < 0) break;
      const after = lower.slice(found + marker.length).replace(/^[\"']+/, '');
      let secret;
      if (marker === 'bearer' && !after.startsWith(':') && !after.startsWith('=')) {
        secret = trimAscii(after);
      } else {
        const separator = trimAscii(after);
        if (!separator.startsWith(':') && !separator.startsWith('=')) {
          offset = found + marker.length;
          continue;
        }
        secret = trimAsciiQuotes(separator.slice(1));
      }
      if (secret.length > 0) return true;
      offset = found + marker.length;
    }
  }
  return false;
}

function looksLikeEmail(value) {
  return value.split(/\s+/).some((part) => {
    const trimmed = part.replace(/^[,.;:()[\]{}<>"']+|[,.;:()[\]{}<>"']+$/g, '');
    const at = trimmed.indexOf('@');
    if (at < 0) return false;
    const left = trimmed.slice(0, at);
    const right = trimmed.slice(at + 1);
    return left.length > 0 && right.length > 0 && right.includes('.');
  });
}
// 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`;
  const correlation = normalizeCorrelation(options.correlation ?? options);
  let queue = [];
  let timer = null;

  function enqueue(lvl, message, fields) {
    if ((LEVELS[lvl] ?? 1) < minRank) return;
    const log = sanitizeLog({
      source,
      line: message,
      level: lvl,
      target: (fields && fields.target) || location.pathname,
      origin: 'client',
      fields,
      ...normalizeCorrelation(fields),
      ts: Math.floor(Date.now() / 1000),
    }, correlation);
    if (!log) return;
    queue.push(log);
    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);
  }
}