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