プロジェクト

全般

プロフィール

バグ #764

未完了

【実装】Phase 3-1: ログ管理機能

Redmine Admin さんが4日前に追加.

ステータス:
新規
優先度:
急いで
担当者:
-
開始日:
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対応
  • フロントエンドコンポーネント
  • 単体テスト
  • 統合テスト
  • ドキュメント作成

表示するデータがありません

他の形式にエクスポート: Atom PDF