Some checks failed
- Add logger.audit/access calls to auth route (login, login_failed, login_blocked, register, password_changed, 2fa_backup_code_used, 2fa_backup_codes_regenerated) — these previously only wrote to DB via raw SQL, bypassing Loki shipper - Replace logger.info with logger.apiCall in callAI() so every AI call ships to Loki with model, tokens, cost, duration - Add device identifier (parsed user agent) to audit and access logs - Fix TTS voice/model provider mismatch: auto-detect Vertex voices (Puck, Charon, Kore, etc.) and ElevenLabs voice IDs, override model to match provider regardless of what model was previously set - Fix TTS discovery: model IDs saved to tts.voice are detected and redirected to tts.model (regex for openai-tts, elevenlabs, vertex-tts) - Fix STT transcription route: add scribe/elevenlabs/transcri to the isTranscriptionModel regex so ElevenLabs Scribe uses /audio/transcriptions endpoint instead of chat completions - Remove OpenObserve/SigNoz code from logger (reverted to Loki-only)
163 lines
6.5 KiB
JavaScript
163 lines
6.5 KiB
JavaScript
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 },
|
|
'anthropic/vendor-model-sonnet-4': { input: 3.00, output: 15.00 },
|
|
'anthropic/vendor-model-3-haiku': { input: 0.25, output: 1.25 },
|
|
'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');
|
|
var line = '[' + now.toISOString() + '] [' + level.toUpperCase() + '] ' + message;
|
|
if (data) line += ' | ' + (typeof data === 'string' ? data : JSON.stringify(data));
|
|
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;
|