Some checks failed
Dev CI / Unit Tests (${{ matrix.app }}) (backend) (push) Blocked by required conditions
Dev CI / Unit Tests (${{ matrix.app }}) (frontend) (push) Blocked by required conditions
Dev CI / Notify Failure (push) Blocked by required conditions
Dev CI / Quality (${{ matrix.app }}) (backend) (push) Has been cancelled
Dev CI / Quality (${{ matrix.app }}) (frontend) (push) Has been cancelled
Aligns dev with the complete application codebase (cicd branch). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
320 lines
9.8 KiB
JavaScript
320 lines
9.8 KiB
JavaScript
'use strict';
|
|
|
|
const express = require('express');
|
|
const { Transform } = require('stream');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3200;
|
|
const LOKI_URL = process.env.LOKI_URL || 'http://loki:3100';
|
|
const API_KEY = process.env.LOG_EXPORTER_API_KEY;
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Simple API key middleware (optional, enabled when LOG_EXPORTER_API_KEY is set).
|
|
*/
|
|
function authMiddleware(req, res, next) {
|
|
if (!API_KEY) return next();
|
|
const key = req.headers['x-api-key'] || req.query.apiKey;
|
|
if (key !== API_KEY) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
/**
|
|
* Build a Loki LogQL query from request params.
|
|
* Supports: service, level, search (free text filter)
|
|
*/
|
|
function buildLogQLQuery({ service, level, search }) {
|
|
const labelFilters = [];
|
|
|
|
if (service && service !== 'all') {
|
|
const services = service.split(',').map((s) => s.trim()).filter(Boolean);
|
|
if (services.length === 1) {
|
|
labelFilters.push(`service="${services[0]}"`);
|
|
} else {
|
|
labelFilters.push(`service=~"${services.join('|')}"`);
|
|
}
|
|
}
|
|
|
|
if (level && level !== 'all') {
|
|
const levels = level.split(',').map((l) => l.trim()).filter(Boolean);
|
|
if (levels.length === 1) {
|
|
labelFilters.push(`level="${levels[0]}"`);
|
|
} else {
|
|
labelFilters.push(`level=~"${levels.join('|')}"`);
|
|
}
|
|
}
|
|
|
|
const streamSelector = labelFilters.length > 0
|
|
? `{${labelFilters.join(', ')}}`
|
|
: `{service=~".+"}`;
|
|
|
|
const lineFilters = search ? ` |= \`${search}\`` : '';
|
|
|
|
return `${streamSelector}${lineFilters}`;
|
|
}
|
|
|
|
/**
|
|
* Query Loki's query_range endpoint and return flattened log entries.
|
|
*/
|
|
async function queryLoki({ query, start, end, limit = 5000 }) {
|
|
const params = new URLSearchParams({
|
|
query,
|
|
start: String(start),
|
|
end: String(end),
|
|
limit: String(Math.min(limit, 5000)),
|
|
direction: 'BACKWARD',
|
|
});
|
|
|
|
const url = `${LOKI_URL}/loki/api/v1/query_range?${params}`;
|
|
|
|
const response = await fetch(url, {
|
|
headers: { 'Accept': 'application/json' },
|
|
signal: AbortSignal.timeout(30000),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text();
|
|
throw new Error(`Loki query failed (${response.status}): ${body}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status !== 'success') {
|
|
throw new Error(`Loki returned status: ${data.status}`);
|
|
}
|
|
|
|
// Flatten streams → individual log entries
|
|
const entries = [];
|
|
for (const stream of data.data.result || []) {
|
|
const labels = stream.stream || {};
|
|
for (const [tsNano, line] of stream.values || []) {
|
|
let parsed = {};
|
|
try {
|
|
parsed = JSON.parse(line);
|
|
} catch {
|
|
parsed = { msg: line };
|
|
}
|
|
|
|
entries.push({
|
|
timestamp: new Date(Math.floor(Number(tsNano) / 1e6)).toISOString(),
|
|
service: labels.service || labels.container || 'unknown',
|
|
level: labels.level || parsed.level || 'info',
|
|
context: labels.context || parsed.context || '',
|
|
message: parsed.msg || parsed.message || line,
|
|
reqId: parsed.reqId || '',
|
|
req_method: parsed.req?.method || '',
|
|
req_url: parsed.req?.url || '',
|
|
res_status: parsed.res?.statusCode || '',
|
|
response_time_ms: parsed.responseTime || '',
|
|
error: parsed.err?.message || '',
|
|
raw: line,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by timestamp ascending
|
|
entries.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Convert array of objects to CSV string.
|
|
*/
|
|
function toCSV(entries) {
|
|
if (entries.length === 0) return '';
|
|
|
|
const headers = [
|
|
'timestamp', 'service', 'level', 'context',
|
|
'message', 'reqId', 'req_method', 'req_url',
|
|
'res_status', 'response_time_ms', 'error',
|
|
];
|
|
|
|
const escape = (val) => {
|
|
if (val === null || val === undefined) return '';
|
|
const str = String(val);
|
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
return `"${str.replace(/"/g, '""')}"`;
|
|
}
|
|
return str;
|
|
};
|
|
|
|
const rows = [headers.join(',')];
|
|
for (const entry of entries) {
|
|
rows.push(headers.map((h) => escape(entry[h])).join(','));
|
|
}
|
|
return rows.join('\n');
|
|
}
|
|
|
|
// ─── Routes ──────────────────────────────────────────────────────────────────
|
|
|
|
app.use(express.json());
|
|
|
|
// Rate limiting (basic — 60 requests/min per IP)
|
|
const requestCounts = new Map();
|
|
setInterval(() => requestCounts.clear(), 60000);
|
|
|
|
app.use((req, res, next) => {
|
|
const ip = req.ip;
|
|
const count = (requestCounts.get(ip) || 0) + 1;
|
|
requestCounts.set(ip, count);
|
|
if (count > 60) {
|
|
return res.status(429).json({ error: 'Too Many Requests' });
|
|
}
|
|
next();
|
|
});
|
|
|
|
// CORS for Grafana / frontend
|
|
app.use((req, res, next) => {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
next();
|
|
});
|
|
|
|
/**
|
|
* GET /health
|
|
*/
|
|
app.get('/health', (req, res) => {
|
|
res.json({ status: 'ok', loki: LOKI_URL });
|
|
});
|
|
|
|
/**
|
|
* GET /api/logs/services
|
|
* Returns the list of services currently emitting logs.
|
|
*/
|
|
app.get('/api/logs/services', authMiddleware, async (req, res) => {
|
|
try {
|
|
const response = await fetch(`${LOKI_URL}/loki/api/v1/label/service/values`, {
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (!response.ok) throw new Error(`Loki error: ${response.status}`);
|
|
const data = await response.json();
|
|
res.json({ services: data.data || [] });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/logs/labels
|
|
* Returns all available label names.
|
|
*/
|
|
app.get('/api/logs/labels', authMiddleware, async (req, res) => {
|
|
try {
|
|
const response = await fetch(`${LOKI_URL}/loki/api/v1/labels`, {
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (!response.ok) throw new Error(`Loki error: ${response.status}`);
|
|
const data = await response.json();
|
|
res.json({ labels: data.data || [] });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/logs/export
|
|
*
|
|
* Query params:
|
|
* - start : ISO date or Unix timestamp in ns (default: 1h ago)
|
|
* - end : ISO date or Unix timestamp in ns (default: now)
|
|
* - service : comma-separated service names (default: all)
|
|
* - level : comma-separated levels: error,warn,info,debug (default: all)
|
|
* - search : free-text search string
|
|
* - limit : max number of log lines (default: 5000, max: 5000)
|
|
* - format : "json" | "csv" (default: json)
|
|
*
|
|
* Examples:
|
|
* GET /api/logs/export?service=backend&level=error&format=csv
|
|
* GET /api/logs/export?start=2024-01-01T00:00:00Z&end=2024-01-02T00:00:00Z&format=json
|
|
*/
|
|
app.get('/api/logs/export', authMiddleware, async (req, res) => {
|
|
try {
|
|
const now = Date.now();
|
|
const ONE_HOUR_NS = 3600 * 1e9;
|
|
const nowNs = BigInt(now) * 1000000n;
|
|
const oneHourAgoNs = nowNs - BigInt(ONE_HOUR_NS);
|
|
|
|
// Parse time range
|
|
const parseTime = (val, defaultNs) => {
|
|
if (!val) return defaultNs;
|
|
// Already in nanoseconds (large number)
|
|
if (/^\d{18,}$/.test(val)) return BigInt(val);
|
|
// Unix timestamp in seconds or ms
|
|
const n = Number(val);
|
|
if (!isNaN(n)) {
|
|
// seconds → ns
|
|
if (n < 1e12) return BigInt(Math.floor(n * 1e9));
|
|
// ms → ns
|
|
if (n < 1e15) return BigInt(n) * 1000000n;
|
|
return BigInt(n);
|
|
}
|
|
// ISO date string
|
|
const ms = Date.parse(val);
|
|
if (isNaN(ms)) throw new Error(`Invalid time value: ${val}`);
|
|
return BigInt(ms) * 1000000n;
|
|
};
|
|
|
|
const startNs = parseTime(req.query.start, oneHourAgoNs);
|
|
const endNs = parseTime(req.query.end, nowNs);
|
|
|
|
if (endNs <= startNs) {
|
|
return res.status(400).json({ error: '"end" must be after "start"' });
|
|
}
|
|
|
|
const format = (req.query.format || 'json').toLowerCase();
|
|
if (!['json', 'csv'].includes(format)) {
|
|
return res.status(400).json({ error: 'format must be "json" or "csv"' });
|
|
}
|
|
|
|
const limit = Math.min(parseInt(req.query.limit, 10) || 5000, 5000);
|
|
|
|
const query = buildLogQLQuery({
|
|
service: req.query.service,
|
|
level: req.query.level,
|
|
search: req.query.search,
|
|
});
|
|
|
|
const entries = await queryLoki({
|
|
query,
|
|
start: startNs.toString(),
|
|
end: endNs.toString(),
|
|
limit,
|
|
});
|
|
|
|
if (format === 'csv') {
|
|
const csv = toCSV(entries);
|
|
const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
return res.send(csv);
|
|
}
|
|
|
|
// JSON response
|
|
res.json({
|
|
total: entries.length,
|
|
query,
|
|
range: {
|
|
from: new Date(Number(startNs / 1000000n)).toISOString(),
|
|
to: new Date(Number(endNs / 1000000n)).toISOString(),
|
|
},
|
|
logs: entries,
|
|
});
|
|
} catch (err) {
|
|
console.error('[log-exporter] Export error:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`[log-exporter] Listening on port ${PORT}`);
|
|
console.log(`[log-exporter] Loki URL: ${LOKI_URL}`);
|
|
console.log(`[log-exporter] API key protection: ${API_KEY ? 'enabled' : 'disabled'}`);
|
|
});
|