#feature: add AI log analysis & some bugfix
This commit is contained in:
126
app/Services/AiService.php
Normal file
126
app/Services/AiService.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Clients\AiClient;
|
||||
|
||||
class AiService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiClient $client,
|
||||
private readonly ConfigService $configService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 检查 AI 服务是否已配置
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->client->isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 AI 提供商配置
|
||||
*/
|
||||
public function getProviders(): array
|
||||
{
|
||||
return $this->client->getProviders();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前激活的提供商
|
||||
*/
|
||||
public function getActiveProvider(): ?array
|
||||
{
|
||||
return $this->client->getActiveProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置激活的提供商
|
||||
*/
|
||||
public function setActiveProvider(string $providerKey): void
|
||||
{
|
||||
$this->client->setActiveProvider($providerKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 AI 提供商配置
|
||||
*
|
||||
* @param array $providers 提供商配置数组
|
||||
*/
|
||||
public function saveProviders(array $providers): void
|
||||
{
|
||||
// 验证配置格式
|
||||
foreach ($providers as $key => $provider) {
|
||||
if (empty($provider['endpoint']) || empty($provider['api_key']) || empty($provider['model'])) {
|
||||
throw new \InvalidArgumentException("提供商 {$key} 配置不完整");
|
||||
}
|
||||
}
|
||||
|
||||
$this->configService->set(
|
||||
'log_analysis.ai_providers',
|
||||
$providers,
|
||||
'AI 服务提供商配置'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加或更新单个提供商
|
||||
*/
|
||||
public function saveProvider(string $key, array $config): void
|
||||
{
|
||||
$providers = $this->getProviders();
|
||||
$providers[$key] = array_merge($providers[$key] ?? [], $config);
|
||||
$this->saveProviders($providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除提供商
|
||||
*/
|
||||
public function deleteProvider(string $key): void
|
||||
{
|
||||
$providers = $this->getProviders();
|
||||
unset($providers[$key]);
|
||||
$this->saveProviders($providers);
|
||||
|
||||
// 如果删除的是当前激活的,清除激活状态
|
||||
$activeKey = $this->configService->get('log_analysis.active_ai_provider');
|
||||
if ($activeKey === $key) {
|
||||
$this->configService->set('log_analysis.active_ai_provider', null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析日志
|
||||
*
|
||||
* @param string $logsContent 日志内容
|
||||
* @param string|null $codeContext 代码上下文
|
||||
* @return array 分析结果
|
||||
*/
|
||||
public function analyzeLogs(string $logsContent, ?string $codeContext = null): array
|
||||
{
|
||||
return $this->client->analyzeLogs($logsContent, $codeContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义提示词分析
|
||||
*
|
||||
* @param string $content 要分析的内容
|
||||
* @param string $prompt 自定义提示词
|
||||
* @return string AI 响应
|
||||
*/
|
||||
public function analyze(string $content, string $prompt): string
|
||||
{
|
||||
return $this->client->chat([
|
||||
['role' => 'user', 'content' => $prompt . "\n\n" . $content],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
public function testConnection(): array
|
||||
{
|
||||
return $this->client->testConnection();
|
||||
}
|
||||
}
|
||||
267
app/Services/CodeContextService.php
Normal file
267
app/Services/CodeContextService.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class CodeContextService
|
||||
{
|
||||
private int $contextLines = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConfigService $configService,
|
||||
private readonly EnvService $envService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 根据 app_name 获取代码仓库路径
|
||||
*
|
||||
* @param string $appName
|
||||
* @return string|null
|
||||
*/
|
||||
public function getRepoPath(string $appName): ?string
|
||||
{
|
||||
$appEnvMap = $this->configService->get('log_analysis.app_env_map', []);
|
||||
|
||||
if (!isset($appEnvMap[$appName])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mapping = $appEnvMap[$appName];
|
||||
$project = $mapping['project'] ?? null;
|
||||
$env = $mapping['env'] ?? null;
|
||||
|
||||
if (!$project || !$env) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$envContent = $this->envService->getEnvContent($project, $env);
|
||||
$repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH');
|
||||
|
||||
if ($repoPath && is_dir($repoPath)) {
|
||||
return $repoPath;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略错误,返回 null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从日志中提取相关代码片段
|
||||
*
|
||||
* @param string $repoPath 代码仓库路径
|
||||
* @param Collection $logs 日志集合
|
||||
* @return string|null 代码上下文
|
||||
*/
|
||||
public function extractRelevantCode(string $repoPath, Collection $logs): ?string
|
||||
{
|
||||
$codeSnippets = [];
|
||||
$processedFiles = [];
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$file = $log['file'] ?? null;
|
||||
$line = $log['line'] ?? null;
|
||||
|
||||
if (!$file || !$line) {
|
||||
// 尝试从 trace 中提取
|
||||
$extracted = $this->extractFileLineFromTrace($log['trace'] ?? '');
|
||||
if ($extracted) {
|
||||
$file = $extracted['file'];
|
||||
$line = $extracted['line'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$file || !$line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构建完整路径
|
||||
$fullPath = $this->resolveFilePath($repoPath, $file);
|
||||
if (!$fullPath || !File::exists($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 避免重复处理同一文件的同一位置
|
||||
$key = "{$fullPath}:{$line}";
|
||||
if (isset($processedFiles[$key])) {
|
||||
continue;
|
||||
}
|
||||
$processedFiles[$key] = true;
|
||||
|
||||
// 提取代码片段
|
||||
$snippet = $this->extractCodeSnippet($fullPath, (int) $line);
|
||||
if ($snippet) {
|
||||
$codeSnippets[] = $snippet;
|
||||
}
|
||||
|
||||
// 限制代码片段数量
|
||||
if (count($codeSnippets) >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($codeSnippets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode("\n\n---\n\n", $codeSnippets);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取指定文件指定行附近的代码
|
||||
*
|
||||
* @param string $filePath 文件路径
|
||||
* @param int $line 行号
|
||||
* @return string|null
|
||||
*/
|
||||
public function extractCodeSnippet(string $filePath, int $line): ?string
|
||||
{
|
||||
if (!File::exists($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = File::get($filePath);
|
||||
$lines = explode("\n", $content);
|
||||
$totalLines = count($lines);
|
||||
|
||||
$startLine = max(1, $line - $this->contextLines);
|
||||
$endLine = min($totalLines, $line + $this->contextLines);
|
||||
|
||||
$snippet = [];
|
||||
$snippet[] = "// File: " . basename($filePath);
|
||||
$snippet[] = "// Lines: {$startLine}-{$endLine} (target: {$line})";
|
||||
$snippet[] = "";
|
||||
|
||||
for ($i = $startLine - 1; $i < $endLine; $i++) {
|
||||
$lineNum = $i + 1;
|
||||
$marker = ($lineNum === $line) ? '>>>' : ' ';
|
||||
$snippet[] = sprintf("%s %4d | %s", $marker, $lineNum, $lines[$i] ?? '');
|
||||
}
|
||||
|
||||
return implode("\n", $snippet);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从堆栈跟踪中提取文件和行号
|
||||
*
|
||||
* @param string $trace
|
||||
* @return array|null ['file' => string, 'line' => int]
|
||||
*/
|
||||
private function extractFileLineFromTrace(string $trace): ?array
|
||||
{
|
||||
// 匹配常见的堆栈跟踪格式
|
||||
// PHP: at /path/to/file.php:123
|
||||
// PHP: #0 /path/to/file.php(123): function()
|
||||
// Java: at com.example.Class.method(File.java:123)
|
||||
|
||||
$patterns = [
|
||||
'/at\s+([^\s:]+):(\d+)/',
|
||||
'/#\d+\s+([^\s(]+)\((\d+)\)/',
|
||||
'/([^\s:]+\.php):(\d+)/',
|
||||
'/\(([^:]+):(\d+)\)/',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $trace, $matches)) {
|
||||
return [
|
||||
'file' => $matches[1],
|
||||
'line' => (int) $matches[2],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文件路径
|
||||
*
|
||||
* @param string $repoPath 仓库根路径
|
||||
* @param string $file 文件路径(可能是相对路径或绝对路径)
|
||||
* @return string|null
|
||||
*/
|
||||
private function resolveFilePath(string $repoPath, string $file): ?string
|
||||
{
|
||||
// 如果是绝对路径且存在
|
||||
if (str_starts_with($file, '/') && File::exists($file)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
// 尝试作为相对路径
|
||||
$fullPath = rtrim($repoPath, '/') . '/' . ltrim($file, '/');
|
||||
if (File::exists($fullPath)) {
|
||||
return $fullPath;
|
||||
}
|
||||
|
||||
// 尝试在仓库中搜索文件名
|
||||
$filename = basename($file);
|
||||
$found = $this->findFileInRepo($repoPath, $filename);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在仓库中搜索文件
|
||||
*
|
||||
* @param string $repoPath
|
||||
* @param string $filename
|
||||
* @return string|null
|
||||
*/
|
||||
private function findFileInRepo(string $repoPath, string $filename): ?string
|
||||
{
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($repoPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getFilename() === $filename) {
|
||||
return $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 .env 内容中解析指定键的值
|
||||
*
|
||||
* @param string $envContent
|
||||
* @param string $key
|
||||
* @return string|null
|
||||
*/
|
||||
private function parseEnvValue(string $envContent, string $key): ?string
|
||||
{
|
||||
$lines = explode("\n", $envContent);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
// 跳过注释和空行
|
||||
if (empty($line) || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($line, "{$key}=")) {
|
||||
$value = substr($line, strlen($key) + 1);
|
||||
// 移除引号
|
||||
$value = trim($value, '"\'');
|
||||
return $value ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置上下文行数
|
||||
*/
|
||||
public function setContextLines(int $lines): void
|
||||
{
|
||||
$this->contextLines = $lines;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,11 @@ class DingTalkService
|
||||
public function sendText(string $message, array $atMobiles = [], bool $atAll = false): void
|
||||
{
|
||||
if (empty($this->webhook)) {
|
||||
Log::warning('DingTalk webhook is not configured, skip sending alert.');
|
||||
Log::warning('DingTalk webhook is not configured, skip sending alert. Alert content logged below.', [
|
||||
'message' => $message,
|
||||
'atMobiles' => $atMobiles,
|
||||
'atAll' => $atAll,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,12 +66,17 @@ class GitMonitorService
|
||||
}
|
||||
|
||||
try {
|
||||
$version = $this->jiraService->getUpcomingReleaseVersion($projectKey);
|
||||
// 先从 master 分支获取当前版本号
|
||||
$currentVersion = $this->getMasterVersion($repoKey, $repoConfig);
|
||||
|
||||
// 根据当前版本号获取下一个版本
|
||||
$version = $this->jiraService->getUpcomingReleaseVersion($projectKey, $currentVersion);
|
||||
if ($version) {
|
||||
$payload['repositories'][$repoKey] = [
|
||||
'version' => $version['version'],
|
||||
'release_date' => $version['release_date'],
|
||||
'branch' => 'release/' . $version['version'],
|
||||
'current_version' => $currentVersion,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@@ -181,16 +186,23 @@ class GitMonitorService
|
||||
];
|
||||
|
||||
foreach ($commits as $commit) {
|
||||
if ($this->isDevelopMerge($path, $commit)) {
|
||||
$isMerge = $this->isMergeCommit($path, $commit);
|
||||
$isConflictResolution = $this->isConflictResolution($path, $commit);
|
||||
|
||||
// 只检测直接从 develop 合并的情况
|
||||
if ($isMerge && $this->isDevelopMerge($path, $commit)) {
|
||||
$issues['develop_merges'][] = $this->getCommitMetadata($path, $commit);
|
||||
}
|
||||
|
||||
$missingFunctions = $this->detectMissingFunctions($path, $commit);
|
||||
if (!empty($missingFunctions)) {
|
||||
$issues['missing_functions'][] = [
|
||||
'commit' => $this->getCommitMetadata($path, $commit),
|
||||
'details' => $missingFunctions,
|
||||
];
|
||||
// 只在 merge 提交或冲突解决提交中检测缺失函数
|
||||
if ($isMerge || $isConflictResolution) {
|
||||
$missingFunctions = $this->detectMissingFunctions($path, $commit);
|
||||
if (!empty($missingFunctions)) {
|
||||
$issues['missing_functions'][] = [
|
||||
'commit' => $this->getCommitMetadata($path, $commit),
|
||||
'details' => $missingFunctions,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,34 +251,52 @@ class GitMonitorService
|
||||
return array_values(array_filter(array_map('trim', explode("\n", $output))));
|
||||
}
|
||||
|
||||
private function isDevelopMerge(string $repoPath, string $commit): bool
|
||||
/**
|
||||
* 判断是否为 merge 提交(有多个父提交)
|
||||
*/
|
||||
private function isMergeCommit(string $repoPath, string $commit): bool
|
||||
{
|
||||
$parents = trim($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%P', $commit]));
|
||||
$parentShas = array_values(array_filter(explode(' ', $parents)));
|
||||
|
||||
if (count($parentShas) < 2) {
|
||||
return false;
|
||||
return count($parentShas) >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为冲突解决提交
|
||||
* 通过检查 commit message 是否包含冲突相关关键词
|
||||
*/
|
||||
private function isConflictResolution(string $repoPath, string $commit): bool
|
||||
{
|
||||
$message = strtolower($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%s', $commit]));
|
||||
|
||||
$conflictKeywords = ['conflict', 'resolve', '冲突', '解决冲突'];
|
||||
foreach ($conflictKeywords as $keyword) {
|
||||
if (str_contains($message, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为直接从 develop 分支合并到 release 的提交
|
||||
* 只检测 commit message 明确包含 "merge.*develop" 或 "develop.*into" 的情况
|
||||
*/
|
||||
private function isDevelopMerge(string $repoPath, string $commit): bool
|
||||
{
|
||||
$message = strtolower($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%s', $commit]));
|
||||
if (str_contains($message, self::DEVELOP_BRANCH)) {
|
||||
|
||||
// 检测 "Merge branch 'develop'" 或 "merge develop into" 等模式
|
||||
// 排除 feature/xxx、hotfix/xxx 等分支的合并
|
||||
if (preg_match("/merge\s+(branch\s+)?['\"]?develop['\"]?(\s+into)?/i", $message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$target = 'origin/' . self::DEVELOP_BRANCH;
|
||||
|
||||
foreach ($parentShas as $parent) {
|
||||
$branches = $this->runGit($repoPath, ['git', 'branch', '-r', '--contains', $parent]);
|
||||
foreach (preg_split('/\R/', $branches) as $branchLine) {
|
||||
$branchLine = trim(str_replace('*', '', $branchLine));
|
||||
if (empty($branchLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($branchLine === $target || str_ends_with($branchLine, '/' . self::DEVELOP_BRANCH)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 检测 "develop into release" 模式
|
||||
if (preg_match("/develop\s+into\s+['\"]?release/i", $message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -331,11 +361,15 @@ class GitMonitorService
|
||||
private function extractFunctionName(string $line): ?string
|
||||
{
|
||||
$trimmed = trim($line);
|
||||
if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '*')) {
|
||||
|
||||
// 跳过注释行
|
||||
if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '*') || str_starts_with($trimmed, '/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/function\s+([A-Za-z0-9_]+)\s*\(/', $trimmed, $matches)) {
|
||||
// 只匹配真正的函数定义,必须以 function 关键字开头,或者以访问修饰符开头
|
||||
// 匹配: function foo(, public function foo(, private static function foo( 等
|
||||
if (preg_match('/^(?:(?:public|protected|private)\s+)?(?:static\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/', $trimmed, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
@@ -495,4 +529,29 @@ class GitMonitorService
|
||||
$directory = $repoConfig['directory'] ?? $repoKey;
|
||||
return $this->projectsPath . '/' . ltrim($directory, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 master 分支读取 version.txt 获取当前版本号
|
||||
*/
|
||||
public function getMasterVersion(string $repoKey, array $repoConfig): ?string
|
||||
{
|
||||
$path = $this->resolveProjectPath($repoKey, $repoConfig);
|
||||
|
||||
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
|
||||
Log::warning('Invalid git repository path', ['repository' => $repoKey, 'path' => $path]);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->runGit($path, ['git', 'fetch', 'origin', 'master']);
|
||||
$version = $this->runGit($path, ['git', 'show', 'origin/master:version.txt']);
|
||||
return trim($version) ?: null;
|
||||
} catch (ProcessFailedException $e) {
|
||||
Log::warning('Failed to read version.txt from master branch', [
|
||||
'repository' => $repoKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,9 +758,13 @@ class JiraService
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的 release 版本
|
||||
* 获取下一个 release 版本
|
||||
* 根据当前版本号,在 Jira 版本列表中找到下一个版本
|
||||
*
|
||||
* @param string $projectKey Jira 项目 key
|
||||
* @param string|null $currentVersion 当前版本号(来自 master 分支的 version.txt)
|
||||
*/
|
||||
public function getUpcomingReleaseVersion(string $projectKey): ?array
|
||||
public function getUpcomingReleaseVersion(string $projectKey, ?string $currentVersion = null): ?array
|
||||
{
|
||||
try {
|
||||
$versions = $this->projectService->getVersions($projectKey);
|
||||
@@ -772,23 +776,66 @@ class JiraService
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = Carbon::now()->startOfDay();
|
||||
// 按版本名称排序(假设版本号格式一致,如 1.0.0, 1.0.1, 1.1.0)
|
||||
$sortedVersions = collect($versions)
|
||||
->filter(fn($version) => !empty($version->name))
|
||||
->sortBy(fn($version) => $version->name, SORT_NATURAL)
|
||||
->values();
|
||||
|
||||
$candidate = collect($versions)
|
||||
->filter(function ($version) use ($now) {
|
||||
if (($version->released ?? false) || empty($version->releaseDate)) {
|
||||
return false;
|
||||
}
|
||||
if ($sortedVersions->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($version->releaseDate)->greaterThanOrEqualTo($now);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
->sortBy(function ($version) {
|
||||
return Carbon::parse($version->releaseDate);
|
||||
})
|
||||
// 如果没有提供当前版本,返回第一个未发布的版本
|
||||
if (empty($currentVersion)) {
|
||||
$candidate = $sortedVersions
|
||||
->filter(fn($version) => !($version->released ?? false))
|
||||
->first();
|
||||
|
||||
if (!$candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
// 找到当前版本在列表中的位置,返回下一个版本
|
||||
$currentIndex = $sortedVersions->search(
|
||||
fn($version) => $version->name === $currentVersion
|
||||
);
|
||||
|
||||
// 如果找不到当前版本,尝试找到第一个大于当前版本的未发布版本
|
||||
if ($currentIndex === false) {
|
||||
$candidate = $sortedVersions
|
||||
->filter(function ($version) use ($currentVersion) {
|
||||
if ($version->released ?? false) {
|
||||
return false;
|
||||
}
|
||||
return version_compare($version->name, $currentVersion, '>');
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
// 从当前版本的下一个开始,找到第一个未发布的版本
|
||||
$candidate = $sortedVersions
|
||||
->slice($currentIndex + 1)
|
||||
->filter(fn($version) => !($version->released ?? false))
|
||||
->first();
|
||||
|
||||
if (!$candidate) {
|
||||
@@ -797,7 +844,9 @@ class JiraService
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'release_date' => Carbon::parse($candidate->releaseDate)->toDateString(),
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
437
app/Services/LogAnalysisService.php
Normal file
437
app/Services/LogAnalysisService.php
Normal file
@@ -0,0 +1,437 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\AnalysisMode;
|
||||
use App\Jobs\LogAnalysisJob;
|
||||
use App\Models\LogAnalysisReport;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class LogAnalysisService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SlsService $slsService,
|
||||
private readonly AiService $aiService,
|
||||
private readonly CodeContextService $codeContextService,
|
||||
private readonly ConfigService $configService,
|
||||
private readonly DingTalkService $dingTalkService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 异步执行日志分析(创建后台任务)
|
||||
*
|
||||
* @param Carbon $from 开始时间
|
||||
* @param Carbon $to 结束时间
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param AnalysisMode $mode 分析模式
|
||||
* @param bool $pushNotification 是否推送通知
|
||||
* @return LogAnalysisReport 创建的报告(状态为 pending)
|
||||
*/
|
||||
public function analyzeAsync(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
AnalysisMode $mode = AnalysisMode::Logs,
|
||||
bool $pushNotification = false
|
||||
): LogAnalysisReport {
|
||||
// 如果没有指定查询条件,默认只获取 ERROR 和 WARNING 级别的日志
|
||||
$effectiveQuery = $query;
|
||||
if (empty($query)) {
|
||||
$effectiveQuery = 'ERROR or WARNING';
|
||||
}
|
||||
|
||||
// 创建 pending 状态的报告
|
||||
$report = LogAnalysisReport::create([
|
||||
'from_time' => $from,
|
||||
'to_time' => $to,
|
||||
'query' => $effectiveQuery,
|
||||
'mode' => $mode->value,
|
||||
'total_logs' => 0,
|
||||
'results' => [],
|
||||
'metadata' => [],
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// 分发后台任务
|
||||
LogAnalysisJob::dispatch(
|
||||
$report->id,
|
||||
$from,
|
||||
$to,
|
||||
$effectiveQuery,
|
||||
$mode,
|
||||
$pushNotification
|
||||
);
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行日志分析
|
||||
*
|
||||
* @param Carbon $from 开始时间
|
||||
* @param Carbon $to 结束时间
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param AnalysisMode $mode 分析模式
|
||||
* @param bool $saveReport 是否保存报告
|
||||
* @return array 分析结果
|
||||
*/
|
||||
public function analyze(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
AnalysisMode $mode = AnalysisMode::Logs,
|
||||
bool $saveReport = true
|
||||
): array {
|
||||
$startTime = microtime(true);
|
||||
|
||||
// 1. 获取日志
|
||||
$logs = $this->slsService->fetchLogs($from, $to, $query);
|
||||
|
||||
if ($logs->isEmpty()) {
|
||||
return $this->buildEmptyResult($from, $to, $query, $mode);
|
||||
}
|
||||
|
||||
// 2. 按 app_name 分组
|
||||
$grouped = $this->slsService->groupByAppName($logs);
|
||||
|
||||
// 3. 分析每个分组
|
||||
$results = [];
|
||||
$settings = $this->configService->get('log_analysis.settings', []);
|
||||
$maxLogsPerApp = $settings['max_logs_per_app'] ?? 500;
|
||||
|
||||
foreach ($grouped as $appName => $appLogs) {
|
||||
$appLogsCollection = collect($appLogs);
|
||||
|
||||
// 限制每个 app 的日志数量
|
||||
if ($appLogsCollection->count() > $maxLogsPerApp) {
|
||||
$appLogsCollection = $appLogsCollection->take($maxLogsPerApp);
|
||||
}
|
||||
|
||||
// 获取代码上下文(如果需要)
|
||||
$codeContext = null;
|
||||
if ($mode === AnalysisMode::LogsWithCode) {
|
||||
$repoPath = $this->codeContextService->getRepoPath($appName);
|
||||
if ($repoPath) {
|
||||
$codeContext = $this->codeContextService->extractRelevantCode($repoPath, $appLogsCollection);
|
||||
}
|
||||
}
|
||||
|
||||
// 准备日志内容
|
||||
$logsContent = $this->formatLogsForAnalysis($appLogsCollection);
|
||||
|
||||
// AI 分析
|
||||
try {
|
||||
$results[$appName] = $this->aiService->analyzeLogs($logsContent, $codeContext);
|
||||
$results[$appName]['log_count'] = $appLogsCollection->count();
|
||||
$results[$appName]['has_code_context'] = $codeContext !== null;
|
||||
} catch (\Exception $e) {
|
||||
$results[$appName] = [
|
||||
'error' => $e->getMessage(),
|
||||
'log_count' => $appLogsCollection->count(),
|
||||
'has_code_context' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$executionTime = (microtime(true) - $startTime) * 1000;
|
||||
|
||||
// 4. 构建结果
|
||||
$result = [
|
||||
'request' => [
|
||||
'from' => $from->format('Y-m-d H:i:s'),
|
||||
'to' => $to->format('Y-m-d H:i:s'),
|
||||
'query' => $query,
|
||||
'mode' => $mode->value,
|
||||
],
|
||||
'results' => $results,
|
||||
'metadata' => [
|
||||
'total_logs' => $logs->count(),
|
||||
'apps_analyzed' => count($results),
|
||||
'execution_time_ms' => round($executionTime),
|
||||
'analyzed_at' => Carbon::now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
];
|
||||
|
||||
// 5. 保存报告
|
||||
if ($saveReport) {
|
||||
$this->saveReport($result, $from, $to, $query, $mode, $logs->count());
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅查询日志(不进行 AI 分析)
|
||||
*
|
||||
* @param Carbon $from
|
||||
* @param Carbon $to
|
||||
* @param string|null $query
|
||||
* @param int|null $limit
|
||||
* @return array
|
||||
*/
|
||||
public function queryLogs(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
?int $limit = null
|
||||
): array {
|
||||
$logs = $this->slsService->fetchLogs($from, $to, $query, $limit);
|
||||
$grouped = $this->slsService->groupByAppName($logs);
|
||||
$statistics = $this->slsService->getStatistics($logs);
|
||||
|
||||
return [
|
||||
'total' => $logs->count(),
|
||||
'statistics' => $statistics,
|
||||
'grouped' => array_map(fn($g) => count($g), $grouped),
|
||||
'logs' => $logs->take(100)->values()->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个 logstore 查询日志(不进行 AI 分析)
|
||||
*
|
||||
* @param Carbon $from
|
||||
* @param Carbon $to
|
||||
* @param string|null $query
|
||||
* @param int|null $limit
|
||||
* @param array|null $logstores
|
||||
* @return array
|
||||
*/
|
||||
public function queryLogsFromMultipleStores(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
?int $limit = null,
|
||||
?array $logstores = null
|
||||
): array {
|
||||
$logstoreData = $this->slsService->fetchLogsFromMultipleStores($from, $to, $query, $limit, $logstores);
|
||||
$statistics = $this->slsService->getMultiStoreStatistics($logstoreData);
|
||||
|
||||
// 合并所有 logstore 的日志用于预览
|
||||
$allLogs = collect();
|
||||
$groupedByLogstore = [];
|
||||
|
||||
foreach ($logstoreData as $logstore => $data) {
|
||||
if ($data['success']) {
|
||||
$allLogs = $allLogs->merge($data['logs']);
|
||||
$groupedByLogstore[$logstore] = [
|
||||
'count' => $data['count'],
|
||||
'success' => true,
|
||||
];
|
||||
} else {
|
||||
$groupedByLogstore[$logstore] = [
|
||||
'count' => 0,
|
||||
'success' => false,
|
||||
'error' => $data['error'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $statistics['total'],
|
||||
'statistics' => $statistics,
|
||||
'grouped_by_logstore' => $groupedByLogstore,
|
||||
'logs' => $allLogs->take(100)->values()->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史报告列表
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $offset
|
||||
* @return array
|
||||
*/
|
||||
public function getReports(int $limit = 20, int $offset = 0): array
|
||||
{
|
||||
$query = LogAnalysisReport::query()
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
$total = $query->count();
|
||||
$reports = $query->skip($offset)->take($limit)->get();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'reports' => $reports->map(function ($report) {
|
||||
return [
|
||||
'id' => $report->id,
|
||||
'from_time' => $report->from_time->format('Y-m-d H:i:s'),
|
||||
'to_time' => $report->to_time->format('Y-m-d H:i:s'),
|
||||
'query' => $report->query,
|
||||
'mode' => $report->mode,
|
||||
'total_logs' => $report->total_logs,
|
||||
'status' => $report->status,
|
||||
'created_at' => $report->created_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个报告详情
|
||||
*
|
||||
* @param int $id
|
||||
* @return array|null
|
||||
*/
|
||||
public function getReport(int $id): ?array
|
||||
{
|
||||
$report = LogAnalysisReport::find($id);
|
||||
|
||||
if (!$report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $report->id,
|
||||
'request' => [
|
||||
'from' => $report->from_time->format('Y-m-d H:i:s'),
|
||||
'to' => $report->to_time->format('Y-m-d H:i:s'),
|
||||
'query' => $report->query,
|
||||
'mode' => $report->mode,
|
||||
],
|
||||
'results' => $report->results,
|
||||
'metadata' => $report->metadata,
|
||||
'status' => $report->status,
|
||||
'error_message' => $report->error_message,
|
||||
'created_at' => $report->created_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送分析结果到钉钉
|
||||
*
|
||||
* @param array $result 分析结果
|
||||
* @return bool
|
||||
*/
|
||||
public function pushToNotification(array $result): bool
|
||||
{
|
||||
$message = $this->formatNotificationMessage($result);
|
||||
|
||||
try {
|
||||
$this->dingTalkService->sendText($message);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日志用于 AI 分析
|
||||
*/
|
||||
private function formatLogsForAnalysis(Collection $logs): string
|
||||
{
|
||||
$formatted = [];
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$line = sprintf(
|
||||
"[%s] [%s] %s",
|
||||
$log['time'] ?? 'N/A',
|
||||
$log['level'] ?? 'N/A',
|
||||
$log['message'] ?? json_encode($log['_raw'] ?? $log)
|
||||
);
|
||||
|
||||
if (!empty($log['trace'])) {
|
||||
$line .= "\n" . $log['trace'];
|
||||
}
|
||||
|
||||
$formatted[] = $line;
|
||||
}
|
||||
|
||||
return implode("\n\n", $formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化通知消息
|
||||
*/
|
||||
private function formatNotificationMessage(array $result): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = "📊 SLS 日志分析报告";
|
||||
$lines[] = "时间范围: {$result['request']['from']} ~ {$result['request']['to']}";
|
||||
$lines[] = "总日志数: {$result['metadata']['total_logs']}";
|
||||
$lines[] = "";
|
||||
|
||||
foreach ($result['results'] as $appName => $appResult) {
|
||||
$lines[] = "【{$appName}】";
|
||||
|
||||
if (isset($appResult['error'])) {
|
||||
$lines[] = " 分析失败: {$appResult['error']}";
|
||||
continue;
|
||||
}
|
||||
|
||||
$impact = $appResult['impact'] ?? 'unknown';
|
||||
$impactEmoji = match ($impact) {
|
||||
'high' => '🔴',
|
||||
'medium' => '🟡',
|
||||
'low' => '🟢',
|
||||
default => '⚪',
|
||||
};
|
||||
|
||||
$lines[] = " 影响级别: {$impactEmoji} {$impact}";
|
||||
$lines[] = " 摘要: " . ($appResult['summary'] ?? 'N/A');
|
||||
|
||||
$anomalies = $appResult['core_anomalies'] ?? [];
|
||||
if (!empty($anomalies)) {
|
||||
$lines[] = " 异常数: " . count($anomalies);
|
||||
foreach (array_slice($anomalies, 0, 3) as $anomaly) {
|
||||
$lines[] = " - [{$anomaly['classification']}] {$anomaly['possible_cause']}";
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = "";
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存分析报告
|
||||
*/
|
||||
private function saveReport(
|
||||
array $result,
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query,
|
||||
AnalysisMode $mode,
|
||||
int $totalLogs
|
||||
): LogAnalysisReport {
|
||||
return LogAnalysisReport::create([
|
||||
'from_time' => $from,
|
||||
'to_time' => $to,
|
||||
'query' => $query,
|
||||
'mode' => $mode->value,
|
||||
'total_logs' => $totalLogs,
|
||||
'results' => $result['results'],
|
||||
'metadata' => $result['metadata'],
|
||||
'status' => 'completed',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建空结果
|
||||
*/
|
||||
private function buildEmptyResult(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query,
|
||||
AnalysisMode $mode
|
||||
): array {
|
||||
return [
|
||||
'request' => [
|
||||
'from' => $from->format('Y-m-d H:i:s'),
|
||||
'to' => $to->format('Y-m-d H:i:s'),
|
||||
'query' => $query,
|
||||
'mode' => $mode->value,
|
||||
],
|
||||
'results' => [],
|
||||
'metadata' => [
|
||||
'total_logs' => 0,
|
||||
'apps_analyzed' => 0,
|
||||
'execution_time_ms' => 0,
|
||||
'analyzed_at' => Carbon::now()->format('Y-m-d H:i:s'),
|
||||
'message' => '未找到匹配的日志',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
288
app/Services/SlsService.php
Normal file
288
app/Services/SlsService.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Clients\SlsClient;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class SlsService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SlsClient $client,
|
||||
private readonly ConfigService $configService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 检查 SLS 是否已配置
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->client->isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志
|
||||
*
|
||||
* @param Carbon $from 开始时间
|
||||
* @param Carbon $to 结束时间
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param int|null $limit 最大返回数量
|
||||
* @return Collection
|
||||
*/
|
||||
public function fetchLogs(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
?int $limit = null
|
||||
): Collection {
|
||||
$settings = $this->configService->get('log_analysis.settings', []);
|
||||
$maxLogs = $limit ?? ($settings['max_logs_per_batch'] ?? 1000);
|
||||
|
||||
$logs = $this->client->getAllLogs(
|
||||
$from->timestamp,
|
||||
$to->timestamp,
|
||||
$query,
|
||||
$maxLogs
|
||||
);
|
||||
|
||||
return collect($logs)->map(function ($log) {
|
||||
return $this->normalizeLog($log);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个 logstore 获取日志
|
||||
*
|
||||
* @param Carbon $from 开始时间
|
||||
* @param Carbon $to 结束时间
|
||||
* @param string|null $query SLS 查询语句
|
||||
* @param int|null $limit 最大返回数量
|
||||
* @param array|null $logstores 要查询的 logstore 列表
|
||||
* @return array 按 logstore 分组的日志数据
|
||||
*/
|
||||
public function fetchLogsFromMultipleStores(
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?string $query = null,
|
||||
?int $limit = null,
|
||||
?array $logstores = null
|
||||
): array {
|
||||
$settings = $this->configService->get('log_analysis.settings', []);
|
||||
$maxLogs = $limit ?? ($settings['max_logs_per_batch'] ?? 1000);
|
||||
|
||||
$results = $this->client->getAllLogsFromMultipleStores(
|
||||
$from->timestamp,
|
||||
$to->timestamp,
|
||||
$query,
|
||||
$maxLogs,
|
||||
$logstores
|
||||
);
|
||||
|
||||
// 标准化每个 logstore 的日志
|
||||
$normalized = [];
|
||||
foreach ($results as $logstore => $data) {
|
||||
$normalized[$logstore] = [
|
||||
'logs' => collect($data['logs'])->map(function ($log) use ($logstore) {
|
||||
$normalizedLog = $this->normalizeLog($log);
|
||||
$normalizedLog['_logstore'] = $logstore;
|
||||
return $normalizedLog;
|
||||
}),
|
||||
'count' => $data['count'],
|
||||
'success' => $data['success'],
|
||||
'error' => $data['error'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置的所有 logstore
|
||||
*/
|
||||
public function getLogstores(): array
|
||||
{
|
||||
return $this->client->getLogstores();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 app_name 分组日志
|
||||
*
|
||||
* @param Collection $logs
|
||||
* @return array<string, Collection>
|
||||
*/
|
||||
public function groupByAppName(Collection $logs): array
|
||||
{
|
||||
$grouped = $logs->groupBy(function ($log) {
|
||||
return $log['app_name'] ?? $log['__source__'] ?? 'unknown';
|
||||
});
|
||||
|
||||
return $grouped->map(function ($group) {
|
||||
return $group->values();
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日志级别过滤
|
||||
*
|
||||
* @param Collection $logs
|
||||
* @param array $levels 要保留的级别 ['ERROR', 'WARN', 'INFO']
|
||||
* @return Collection
|
||||
*/
|
||||
public function filterByLevel(Collection $logs, array $levels): Collection
|
||||
{
|
||||
$levels = array_map('strtoupper', $levels);
|
||||
|
||||
return $logs->filter(function ($log) use ($levels) {
|
||||
$level = strtoupper($log['level'] ?? $log['__level__'] ?? '');
|
||||
return in_array($level, $levels);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志分布直方图
|
||||
*
|
||||
* @param Carbon $from
|
||||
* @param Carbon $to
|
||||
* @param string|null $query
|
||||
* @return array
|
||||
*/
|
||||
public function getHistogram(Carbon $from, Carbon $to, ?string $query = null): array
|
||||
{
|
||||
return $this->client->getHistograms(
|
||||
$from->timestamp,
|
||||
$to->timestamp,
|
||||
$query
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志统计信息
|
||||
*
|
||||
* @param Collection $logs
|
||||
* @return array
|
||||
*/
|
||||
public function getStatistics(Collection $logs): array
|
||||
{
|
||||
$byLevel = $logs->groupBy(function ($log) {
|
||||
return strtoupper($log['level'] ?? $log['__level__'] ?? 'UNKNOWN');
|
||||
})->map(fn ($group) => $group->count());
|
||||
|
||||
$byApp = $logs->groupBy(function ($log) {
|
||||
return $log['app_name'] ?? $log['__source__'] ?? 'unknown';
|
||||
})->map(fn ($group) => $group->count());
|
||||
|
||||
return [
|
||||
'total' => $logs->count(),
|
||||
'by_level' => $byLevel->toArray(),
|
||||
'by_app' => $byApp->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多 logstore 的统计信息
|
||||
*
|
||||
* @param array $logstoreData 按 logstore 分组的日志数据
|
||||
* @return array
|
||||
*/
|
||||
public function getMultiStoreStatistics(array $logstoreData): array
|
||||
{
|
||||
$totalCount = 0;
|
||||
$byLogstore = [];
|
||||
$byLevel = [];
|
||||
$byApp = [];
|
||||
|
||||
foreach ($logstoreData as $logstore => $data) {
|
||||
if (!$data['success']) {
|
||||
$byLogstore[$logstore] = [
|
||||
'count' => 0,
|
||||
'success' => false,
|
||||
'error' => $data['error'],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$logs = $data['logs'];
|
||||
$count = $logs->count();
|
||||
$totalCount += $count;
|
||||
|
||||
$byLogstore[$logstore] = [
|
||||
'count' => $count,
|
||||
'success' => true,
|
||||
];
|
||||
|
||||
// 按级别统计
|
||||
$logs->groupBy(function ($log) {
|
||||
return strtoupper($log['level'] ?? $log['__level__'] ?? 'UNKNOWN');
|
||||
})->each(function ($group, $level) use (&$byLevel) {
|
||||
$byLevel[$level] = ($byLevel[$level] ?? 0) + $group->count();
|
||||
});
|
||||
|
||||
// 按应用统计
|
||||
$logs->groupBy(function ($log) {
|
||||
return $log['app_name'] ?? $log['__source__'] ?? 'unknown';
|
||||
})->each(function ($group, $app) use (&$byApp) {
|
||||
$byApp[$app] = ($byApp[$app] ?? 0) + $group->count();
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $totalCount,
|
||||
'by_logstore' => $byLogstore,
|
||||
'by_level' => $byLevel,
|
||||
'by_app' => $byApp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
public function testConnection(): bool
|
||||
{
|
||||
return $this->client->testConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化日志格式
|
||||
*/
|
||||
private function normalizeLog(array $log): array
|
||||
{
|
||||
// 如果日志内容是 JSON 字符串(某些情况下 SLS 会将整条日志作为一个字段返回)
|
||||
// 尝试解析 content 或 message 字段
|
||||
if (isset($log['content']) && is_string($log['content']) && str_starts_with(trim($log['content']), '{')) {
|
||||
$decoded = json_decode($log['content'], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$log = array_merge($log, $decoded);
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试提取常见字段,优先使用业务字段,其次使用 SLS 系统字段
|
||||
$normalized = [
|
||||
'time' => $log['date'] ?? $log['time'] ?? $log['timestamp'] ?? $log['__time__'] ?? null,
|
||||
'level' => $log['level'] ?? $log['__level__'] ?? $log['severity'] ?? null,
|
||||
'message' => $log['message'] ?? $log['msg'] ?? $log['content'] ?? null,
|
||||
'app_name' => $log['app_name'] ?? $log['application'] ?? $log['service'] ?? $log['__source__'] ?? null,
|
||||
'trace' => $log['stack_trace'] ?? $log['trace'] ?? $log['exception'] ?? null,
|
||||
'file' => $log['file'] ?? $log['filename'] ?? null,
|
||||
'line' => $log['line'] ?? $log['lineno'] ?? null,
|
||||
];
|
||||
|
||||
// 格式化时间
|
||||
if (is_numeric($normalized['time'])) {
|
||||
// Unix 时间戳
|
||||
$normalized['time'] = Carbon::createFromTimestamp($normalized['time'])->format('Y-m-d H:i:s');
|
||||
} elseif (is_string($normalized['time']) && !empty($normalized['time'])) {
|
||||
// ISO 8601 格式或其他字符串格式
|
||||
try {
|
||||
$normalized['time'] = Carbon::parse($normalized['time'])->format('Y-m-d H:i:s');
|
||||
} catch (\Exception $e) {
|
||||
// 如果解析失败,保持原样
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原始数据
|
||||
$normalized['_raw'] = $log;
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user