操作
バグ #764
未完了【実装】Phase 3-1: ログ管理機能
ステータス:
新規
優先度:
急いで
担当者:
-
開始日:
2025-06-26
期日:
進捗率:
0%
予定工数:
説明
🎯 概要¶
インフラヘルパーサービスにログ管理機能を実装する。Phase 3の第1弾として、最も使用頻度が高く、日常運用に必須の機能から着手する。
📋 実装内容¶
1. バックエンドAPI実装
ログサービスクラス作成¶
// backend/services/logService.js
const Docker = require('dockerode');
const docker = new Docker();
class LogService {
constructor() {
this.logCache = new Map();
this.activeStreams = new Map();
}
// コンテナログ取得
async getContainerLogs(containerName, options = {}) {
const {
lines = 100,
since = null,
until = null,
timestamps = true,
follow = false
} = options;
try {
const container = docker.getContainer(containerName);
const logOptions = {
stdout: true,
stderr: true,
timestamps,
tail: lines,
follow
};
if (since) {
logOptions.since = Math.floor(new Date(since).getTime() / 1000);
}
if (until) {
logOptions.until = Math.floor(new Date(until).getTime() / 1000);
}
const stream = await container.logs(logOptions);
if (follow) {
return stream; // ストリームを返す
} else {
return await this.parseLogStream(stream);
}
} catch (error) {
throw new Error(`Failed to get logs for ${containerName}: ${error.message}`);
}
}
// ログストリームのパース
async parseLogStream(stream) {
return new Promise((resolve, reject) => {
const logs = [];
let buffer = '';
stream.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop(); // 最後の不完全な行を保持
lines.forEach(line => {
if (line.trim()) {
logs.push(this.parseLogLine(line));
}
});
});
stream.on('end', () => {
if (buffer.trim()) {
logs.push(this.parseLogLine(buffer));
}
resolve(logs);
});
stream.on('error', reject);
});
}
// ログ行のパース
parseLogLine(line) {
// Dockerログフォーマットの解析
// フォーマット: [STREAM_TYPE][TIMESTAMP] MESSAGE
const timestampMatch = line.match(/^.{8}(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s(.*)$/);
if (timestampMatch) {
const [, timestamp, message] = timestampMatch;
const level = this.detectLogLevel(message);
return {
timestamp,
message,
level,
raw: line
};
}
return {
timestamp: new Date().toISOString(),
message: line,
level: 'info',
raw: line
};
}
// ログレベルの検出
detectLogLevel(message) {
const lowerMessage = message.toLowerCase();
if (lowerMessage.includes('error') || lowerMessage.includes('fatal')) {
return 'error';
} else if (lowerMessage.includes('warn') || lowerMessage.includes('warning')) {
return 'warn';
} else if (lowerMessage.includes('debug')) {
return 'debug';
}
return 'info';
}
// ログ検索
async searchLogs(searchParams) {
const {
query = '',
containers = [],
dateRange = {},
severity = [],
limit = 1000
} = searchParams;
const results = [];
for (const containerName of containers) {
try {
const logs = await this.getContainerLogs(containerName, {
lines: limit,
since: dateRange.start,
until: dateRange.end
});
const filtered = logs.filter(log => {
// クエリマッチ
if (query && !log.message.toLowerCase().includes(query.toLowerCase())) {
return false;
}
// 重要度フィルタ
if (severity.length > 0 && !severity.includes(log.level)) {
return false;
}
return true;
});
results.push({
container: containerName,
logs: filtered
});
} catch (error) {
console.error(`Error searching logs for ${containerName}:`, error);
}
}
return results;
}
// WebSocketストリーミング
streamLogs(containerName, ws) {
// 既存のストリームがあれば停止
this.stopStream(containerName);
const container = docker.getContainer(containerName);
container.logs({
stdout: true,
stderr: true,
follow: true,
timestamps: true,
tail: 100
}, (err, stream) => {
if (err) {
ws.send(JSON.stringify({
type: 'error',
message: err.message
}));
return;
}
this.activeStreams.set(containerName, stream);
stream.on('data', (chunk) => {
const lines = chunk.toString().split('\n').filter(line => line.trim());
lines.forEach(line => {
const logEntry = this.parseLogLine(line);
ws.send(JSON.stringify({
type: 'log',
container: containerName,
data: logEntry
}));
});
});
stream.on('error', (error) => {
ws.send(JSON.stringify({
type: 'error',
message: error.message
}));
this.stopStream(containerName);
});
stream.on('end', () => {
ws.send(JSON.stringify({
type: 'end',
message: 'Log stream ended'
}));
this.stopStream(containerName);
});
});
}
// ストリームの停止
stopStream(containerName) {
const stream = this.activeStreams.get(containerName);
if (stream) {
stream.destroy();
this.activeStreams.delete(containerName);
}
}
// ログのダウンロード用フォーマット
async exportLogs(containerName, format = 'txt', options = {}) {
const logs = await this.getContainerLogs(containerName, options);
switch (format) {
case 'json':
return JSON.stringify(logs, null, 2);
case 'csv':
const headers = 'Timestamp,Level,Message\n';
const rows = logs.map(log =>
`"${log.timestamp}","${log.level}","${log.message.replace(/"/g, '""')}"`
).join('\n');
return headers + rows;
case 'txt':
default:
return logs.map(log =>
`[${log.timestamp}] [${log.level.toUpperCase()}] ${log.message}`
).join('\n');
}
}
}
module.exports = LogService;
APIエンドポイント実装¶
// backend/routes/logs.js
const express = require('express');
const router = express.Router();
const LogService = require('../services/logService');
const { authenticateToken } = require('../auth');
const logService = new LogService();
// コンテナログ取得
router.get('/containers/:containerName', authenticateToken, async (req, res) => {
try {
const { containerName } = req.params;
const { lines, since, until, follow } = req.query;
if (follow === 'true') {
// Server-Sent Events for real-time logs
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
const stream = await logService.getContainerLogs(containerName, {
lines: parseInt(lines) || 100,
since,
until,
follow: true
});
stream.on('data', (chunk) => {
const lines = chunk.toString().split('\n').filter(line => line.trim());
lines.forEach(line => {
const log = logService.parseLogLine(line);
res.write(`data: ${JSON.stringify(log)}\n\n`);
});
});
stream.on('end', () => {
res.end();
});
req.on('close', () => {
stream.destroy();
});
} else {
const logs = await logService.getContainerLogs(containerName, {
lines: parseInt(lines) || 100,
since,
until
});
res.json({
success: true,
container: containerName,
count: logs.length,
logs
});
}
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// ログ検索
router.post('/search', authenticateToken, async (req, res) => {
try {
const results = await logService.searchLogs(req.body);
res.json({
success: true,
results
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// ログダウンロード
router.get('/download/:containerName', authenticateToken, async (req, res) => {
try {
const { containerName } = req.params;
const { format = 'txt', lines, since, until, compress } = req.query;
const content = await logService.exportLogs(containerName, format, {
lines: parseInt(lines) || 1000,
since,
until
});
const filename = `${containerName}_logs_${new Date().toISOString()}.${format}`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
if (format === 'json') {
res.setHeader('Content-Type', 'application/json');
} else if (format === 'csv') {
res.setHeader('Content-Type', 'text/csv');
} else {
res.setHeader('Content-Type', 'text/plain');
}
if (compress === 'true') {
const zlib = require('zlib');
res.setHeader('Content-Encoding', 'gzip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}.gz"`);
zlib.gzip(content, (err, compressed) => {
if (err) {
res.status(500).json({ error: err.message });
} else {
res.send(compressed);
}
});
} else {
res.send(content);
}
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
module.exports = router;
2. WebSocket対応
// backend/websocket.js に追加
// ログストリーミング対応
ws.on('message', (data) => {
const message = JSON.parse(data);
if (message.type === 'subscribe_logs') {
const { container } = message;
logService.streamLogs(container, ws);
} else if (message.type === 'unsubscribe_logs') {
const { container } = message;
logService.stopStream(container);
}
});
3. フロントエンド実装
- ログビューアコンポーネント
- リアルタイム表示
- フィルタリング機能
- ダウンロード機能
4. データベーススキーマ
-- ログ検索の高速化のためのインデックステーブル(オプション)
CREATE TABLE IF NOT EXISTS log_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
container_name TEXT NOT NULL,
timestamp DATETIME NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_log_container_timestamp ON log_index(container_name, timestamp);
CREATE INDEX idx_log_level ON log_index(level);
🎯 実装目標¶
- コンテナログのリアルタイム表示
- ログの検索・フィルタリング
- ログのエクスポート機能
- WebSocketによるストリーミング
- パフォーマンスの最適化
📅 実装期間¶
2週間(2025年7月10日完了予定)
✅ 完了条件¶
- APIエンドポイントの実装
- ログサービスクラスの実装
- WebSocket対応
- フロントエンドコンポーネント
- 単体テスト
- 統合テスト
- ドキュメント作成
表示するデータがありません
操作