const fs = require('fs'); const path = require('path'); const db = require('../db/database'); const { redact } = require('./redact'); const queues = require('./auditQueue'); var parseUA; try { parseUA = require('./sessions').parseUserAgent; } catch(e) { parseUA = function(ua) { return ua || 'unknown'; }; } var LOG_DIR = path.join(__dirname, '../../data/logs'); if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } // Loki log shipping (optional — set LOKI_URL env var) var LOKI_URL = process.env.LOKI_URL; function pushToLoki(labels, message) { if (!LOKI_URL) return; var payload = { streams: [{ stream: Object.assign({ app: 'pedscribe' }, labels), values: [[String(Date.now() * 1000000), typeof message === 'string' ? message : JSON.stringify(message)]] }] }; fetch(LOKI_URL + '/loki/api/v1/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(function() {}); // fire-and-forget } function estimateCost(model, inputTokens, outputTokens) { if (!model || !inputTokens) return 0; var rates = { 'google/gemini-2.5-flash': { input: 0.075, output: 0.30 }, 'google/gemini-2.5-pro': { input: 1.25, output: 5.00 }, 'deepseek/deepseek-chat-v3-0324': { input: 0.14, output: 0.28 }, 'deepseek/deepseek-r1': { input: 0.55, output: 2.19 }, 'deepseek/deepseek-r1:free': { input: 0, output: 0 }, 'qwen/qwen3-30b-a3b:free': { input: 0, output: 0 }, 'openai/gpt-4.1-mini': { input: 0.40, output: 1.60 }, 'meta-llama/llama-3.3-70b-instruct': { input: 0.10, output: 0.16 } }; var rate = rates[model] || { input: 0.50, output: 1.00 }; return ((inputTokens / 1000000) * rate.input) + (((outputTokens || 0) / 1000000) * rate.output); } var logger = { audit: function(userId, action, details, req, extra) { var e = extra || {}; var safeDetails = redact(typeof details === 'string' ? details : JSON.stringify(details)); queues.audit.push({ user_id: userId || null, action: action, category: e.category || 'general', details: safeDetails, ip_address: req ? (req.ip || null) : null, user_agent: req ? (req.headers['user-agent'] || '').substring(0, 255) : null, model_used: e.model || null, tokens_used: e.tokens || null, duration_ms: e.duration || null, status: e.status || 'success' }); var auditLabels = { type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' }; var auditMsg = JSON.stringify({ userId: userId, action: action, details: safeDetails, ip: req ? req.ip : null, userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null, device: req ? parseUA(req.headers['user-agent']) : null, sessionId: req ? (req.sessionId || null) : null, model: e.model || null, status: e.status || 'success' }); pushToLoki(auditLabels, auditMsg); }, apiCall: function(userId, endpoint, data) { var d = data || {}; var cost = estimateCost(d.model, d.tokensInput, d.tokensOutput); queues.api.push({ user_id: userId || null, endpoint: endpoint, method: d.method || 'POST', status_code: d.statusCode || 200, request_size: d.requestSize || 0, response_size: d.responseSize || 0, model_used: d.model || null, tokens_input: d.tokensInput || 0, tokens_output: d.tokensOutput || 0, cost_estimate: cost, duration_ms: d.duration || 0, ip_address: d.ip || null, error: d.error || null }); var apiLabels = { type: 'api_call', endpoint: endpoint, model: d.model || '' }; var apiMsg = JSON.stringify({ userId: userId, endpoint: endpoint, model: d.model, tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration, ip: d.ip || null, status: d.statusCode || 200 }); pushToLoki(apiLabels, apiMsg); }, access: function(userId, action, req, success) { queues.access.push({ user_id: userId || null, action: action, ip_address: req ? (req.ip || null) : null, user_agent: req ? (req.headers['user-agent'] || '').substring(0, 255) : null, success: success ? true : false }); var accessLabels = { type: 'access', action: action }; var accessMsg = JSON.stringify({ userId: userId, action: action, ip: req ? req.ip : null, userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null, device: req ? parseUA(req.headers['user-agent']) : null, sessionId: req ? (req.sessionId || null) : null, success: success }); pushToLoki(accessLabels, accessMsg); }, file: function(level, message, data) { try { var now = new Date(); var date = now.toISOString().split('T')[0]; var logFile = path.join(LOG_DIR, date + '.log'); // Defensive redaction: route both message and optional data through // redact() so PHI patterns (SSN, phone, email, DoB) and note-body // heuristics can't leak to the daily log file if a caller accidentally // passes a request body, clinical string, or stack trace containing // transcript text. var safeMessage = redact(String(message == null ? '' : message)); var line = '[' + now.toISOString() + '] [' + level.toUpperCase() + '] ' + safeMessage; if (data != null) { var raw = typeof data === 'string' ? data : JSON.stringify(data); line += ' | ' + redact(raw); } line += '\n'; fs.appendFileSync(logFile, line); if (level === 'error') console.error(line.trim()); } catch (e) {} }, info: function(msg, data) { this.file('info', msg, data); }, warn: function(msg, data) { this.file('warn', msg, data); }, error: function(msg, data) { this.file('error', msg, data); }, getAuditLogs: function(userId, limit) { return db.all('SELECT * FROM audit_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]); }, getApiLogs: function(userId, limit) { return db.all('SELECT * FROM api_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]); }, getAccessLogs: function(userId, limit) { return db.all('SELECT * FROM access_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]); }, getUsageSummary: function(userId, days) { var since = new Date(Date.now() - (days || 30) * 86400000).toISOString(); return db.all( 'SELECT COUNT(*) as total_calls, SUM(tokens_input) as total_input_tokens, SUM(tokens_output) as total_output_tokens, SUM(cost_estimate) as total_estimated_cost, AVG(duration_ms) as avg_duration_ms, model_used, endpoint FROM api_log WHERE user_id = ? AND timestamp > ? GROUP BY model_used, endpoint ORDER BY total_calls DESC', [userId, since] ); } }; console.log('✅ Logger initialized'); module.exports = logger;