setTimeout(5); $check->run(); if (!$check->isSuccessful()) { return 'Docker CLI is not available in this environment.'; } $args = ['docker', 'logs', '--tail', (string) $lines]; if (null !== $since) { $args[] = '--since'; $args[] = $since; } $args[] = $containerName; $process = new Process($args); $process->setTimeout(10); $process->run(); $output = $process->getOutput() . $process->getErrorOutput(); return $this->formatDockerOutput($output); } private function formatDockerOutput(string $output): string { $rawLines = explode("\n", trim($output)); $formatted = []; foreach ($rawLines as $line) { if ('' === $line) { continue; } // Try parsing as Symfony monolog JSON format $parsed = $this->parseSymfonyLogLine($line); if (null !== $parsed) { if ('doctrine' === $parsed['channel']) { continue; } $formatted[] = sprintf('[%s] %s.%s: %s', $parsed['date'], $parsed['channel'], $parsed['level'], $parsed['message']); continue; } // Try parsing as JSON log ({"message":"...","level":...}) $json = json_decode($line, true); if (\is_array($json) && isset($json['message'])) { $date = isset($json['datetime']) ? substr($json['datetime'], 0, 19) : ''; $date = str_replace('T', ' ', $date); $channel = $json['channel'] ?? 'app'; $level = $json['level_name'] ?? 'INFO'; if ('doctrine' === $channel) { continue; } $formatted[] = sprintf('[%s] %s.%s: %s', $date, $channel, $level, $json['message']); continue; } // Keep raw lines that don't match any format (nginx access logs, etc.) $formatted[] = $line; } return implode("\n", $formatted); } public function getSymfonyLog(string $containerName, string $logPath, int $lines = 100, ?string $level = null): string { $check = new Process(['which', 'docker']); $check->setTimeout(5); $check->run(); if (!$check->isSuccessful()) { // Fallback: try reading from filesystem (dev mode) $localPath = $this->pathResolver->resolve($logPath); if (!file_exists($localPath)) { return sprintf('Log file not found: %s (Docker CLI unavailable, local path: %s)', $logPath, $localPath); } $readLines = (null !== $level && '' !== $level) ? $lines * 5 : $lines; $process = new Process(['tail', '-n', (string) $readLines, $localPath]); $process->setTimeout(10); $process->run(); return $this->formatSymfonyOutput($process->getOutput(), $lines, $level); } // Read more lines than requested to compensate for filtering $readLines = (null !== $level && '' !== $level) ? $lines * 5 : $lines; $process = new Process(['docker', 'exec', $containerName, 'tail', '-n', (string) $readLines, $logPath]); $process->setTimeout(10); $process->run(); if (!$process->isSuccessful()) { return sprintf('Error reading log: %s', trim($process->getErrorOutput())); } return $this->formatSymfonyOutput($process->getOutput(), $lines, $level); } private function formatSymfonyOutput(string $output, int $lines, ?string $level): string { $rawLines = explode("\n", trim($output)); $formatted = []; foreach ($rawLines as $line) { if ('' === $line) { continue; } $parsed = $this->parseSymfonyLogLine($line); if (null === $parsed) { continue; } // Skip noisy channels if ('doctrine' === $parsed['channel']) { continue; } if (null !== $level && '' !== $level && !str_contains(strtoupper($parsed['level']), strtoupper($level))) { continue; } $formatted[] = sprintf('[%s] %s.%s: %s', $parsed['date'], $parsed['channel'], $parsed['level'], $parsed['message']); } // Keep only the last N lines after filtering $formatted = \array_slice($formatted, -$lines); return implode("\n", $formatted); } /** * @return array{date: string, level: string, channel: string, message: string}|null */ private function parseSymfonyLogLine(string $line): ?array { // Standard Symfony monolog format: [2026-04-03T15:33:19.304937+02:00] channel.LEVEL: message {context} [] if (preg_match('/^\[([^\]]+)\]\s+(\w+)\.(\w+):\s+(.+)$/', $line, $matches)) { $date = substr($matches[1], 0, 19); // Trim to YYYY-MM-DDTHH:MM:SS $message = $matches[4]; // Remove JSON context at the end: {"key":"value"} [] $message = preg_replace('/\s*\{.*\}\s*\[\]\s*$/', '', $message) ?? $message; // Remove trailing [] $message = preg_replace('/\s*\[\]\s*$/', '', $message) ?? $message; return [ 'date' => str_replace('T', ' ', $date), 'level' => $matches[3], 'channel' => $matches[2], 'message' => $message, ]; } return null; } }