Menu
Public repository / 3a8e394f852804fb6cb8b400b55e2afbf0b6dddd
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);
}
}