From cceda8abf276ffbc643e55a9c32b4a1bac7b597f Mon Sep 17 00:00:00 2001 From: Haylan Date: Fri, 29 May 2026 18:57:25 +0200 Subject: [PATCH] feat: integrate Monolog for enhanced logging across providers and controller --- CLAUDE.md | 1 + composer.json | 1 + config/bundles.php | 1 + config/packages/monolog.yaml | 26 +++++ config/reference.php | 155 +++++++++++++++++++++++++++++ public/favicon.ico | 0 public/robots.txt | 2 + src/Controller/GraphController.php | 10 +- src/Service/GitHubProvider.php | 10 ++ src/Service/GitLabProvider.php | 11 ++ src/Service/GiteaProvider.php | 11 ++ 11 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 config/packages/monolog.yaml create mode 100644 public/favicon.ico create mode 100644 public/robots.txt diff --git a/CLAUDE.md b/CLAUDE.md index d656647..7742d9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,7 @@ Run vendor/bin/phpunit after each change to confirm tests stay green. | Combining Red + Green in one request | No failing baseline | Always separate the two phases | ### Running tests +Prever to use the tests in `docker compose exec graph` ```bash # Run full suite diff --git a/composer.json b/composer.json index 31c3718..60fbf9c 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "symfony/console": "7.4.*", "symfony/framework-bundle": "7.4.*", "symfony/http-client": "7.4.*", + "symfony/monolog-bundle": "^4.0", "symfony/runtime": "7.4.*", "symfony/yaml": "7.4.*" }, diff --git a/config/bundles.php b/config/bundles.php index 16b6e21..80187e8 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -2,6 +2,7 @@ return [ Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], EightPoints\Bundle\GuzzleBundle\EightPointsGuzzleBundle::class => ['all' => true], IDCI\Bundle\GraphQLClientBundle\IDCIGraphQLClientBundle::class => ['all' => true], ]; diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml new file mode 100644 index 0000000..a339a15 --- /dev/null +++ b/config/packages/monolog.yaml @@ -0,0 +1,26 @@ +monolog: + channels: + - deprecation + +when@dev: + monolog: + handlers: + main: + type: stream + path: '%kernel.logs_dir%/%kernel.environment%.log' + level: debug + channels: ['!event'] + +when@prod: + monolog: + handlers: + main: + type: stream + path: php://stderr + level: warning + channels: ['!event'] + deprecation: + type: stream + channels: [deprecation] + path: '%kernel.logs_dir%/%kernel.environment%.deprecations.log' + level: info diff --git a/config/reference.php b/config/reference.php index 2eb9e66..ea15291 100644 --- a/config/reference.php +++ b/config/reference.php @@ -690,6 +690,149 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * enabled?: bool|Param, // Default: false * }, * } + * @psalm-type MonologConfig = array{ + * use_microseconds?: scalar|Param|null, // Default: true + * channels?: list, + * handlers?: array, + * }>, + * accepted_levels?: list, + * min_level?: scalar|Param|null, // Default: "DEBUG" + * max_level?: scalar|Param|null, // Default: "EMERGENCY" + * buffer_size?: scalar|Param|null, // Default: 0 + * flush_on_overflow?: bool|Param, // Default: false + * handler?: scalar|Param|null, + * url?: scalar|Param|null, + * exchange?: scalar|Param|null, + * exchange_name?: scalar|Param|null, // Default: "log" + * channel?: scalar|Param|null, // Default: null + * bot_name?: scalar|Param|null, // Default: "Monolog" + * use_attachment?: scalar|Param|null, // Default: true + * use_short_attachment?: scalar|Param|null, // Default: false + * include_extra?: scalar|Param|null, // Default: false + * icon_emoji?: scalar|Param|null, // Default: null + * webhook_url?: scalar|Param|null, + * exclude_fields?: list, + * token?: scalar|Param|null, + * region?: scalar|Param|null, + * source?: scalar|Param|null, + * use_ssl?: bool|Param, // Default: true + * user?: mixed, + * title?: scalar|Param|null, // Default: null + * host?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 514 + * config?: list, + * members?: list, + * connection_string?: scalar|Param|null, + * timeout?: scalar|Param|null, + * time?: scalar|Param|null, // Default: 60 + * deduplication_level?: scalar|Param|null, // Default: 400 + * store?: scalar|Param|null, // Default: null + * connection_timeout?: scalar|Param|null, + * persistent?: bool|Param, + * message_type?: scalar|Param|null, // Default: 0 + * parse_mode?: scalar|Param|null, // Default: null + * disable_webpage_preview?: bool|Param|null, // Default: null + * disable_notification?: bool|Param|null, // Default: null + * split_long_messages?: bool|Param, // Default: false + * delay_between_messages?: bool|Param, // Default: false + * topic?: int|Param, // Default: null + * factor?: int|Param, // Default: 1 + * tags?: string|list, + * console_formatter_options?: mixed, // Default: [] + * formatter?: scalar|Param|null, + * nested?: bool|Param, // Default: false + * publisher?: string|array{ + * id?: scalar|Param|null, + * hostname?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 12201 + * chunk_size?: scalar|Param|null, // Default: 1420 + * encoder?: "json"|"compressed_json"|Param, + * }, + * mongodb?: string|array{ + * id?: scalar|Param|null, // ID of a MongoDB\Client service + * uri?: scalar|Param|null, + * username?: scalar|Param|null, + * password?: scalar|Param|null, + * database?: scalar|Param|null, // Default: "monolog" + * collection?: scalar|Param|null, // Default: "logs" + * }, + * elasticsearch?: string|array{ + * id?: scalar|Param|null, + * hosts?: list, + * host?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 9200 + * transport?: scalar|Param|null, // Default: "Http" + * user?: scalar|Param|null, // Default: null + * password?: scalar|Param|null, // Default: null + * }, + * index?: scalar|Param|null, // Default: "monolog" + * document_type?: scalar|Param|null, // Default: "logs" + * ignore_error?: scalar|Param|null, // Default: false + * redis?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * password?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 6379 + * database?: scalar|Param|null, // Default: 0 + * key_name?: scalar|Param|null, // Default: "monolog_redis" + * }, + * predis?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * }, + * from_email?: scalar|Param|null, + * to_email?: string|list, + * subject?: scalar|Param|null, + * content_type?: scalar|Param|null, // Default: null + * headers?: list, + * mailer?: scalar|Param|null, // Default: null + * email_prototype?: string|array{ + * id?: scalar|Param|null, + * method?: scalar|Param|null, // Default: null + * }, + * verbosity_levels?: array{ + * VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR" + * VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING" + * VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE" + * VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO" + * VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG" + * }, + * channels?: string|array{ + * type?: scalar|Param|null, + * elements?: list, + * }, + * }>, + * } * @psalm-type EightPointsGuzzleConfig = array{ * clients?: array, + * "when@prod"?: array, * ... * } */ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/src/Controller/GraphController.php b/src/Controller/GraphController.php index 2b4ae20..455b980 100644 --- a/src/Controller/GraphController.php +++ b/src/Controller/GraphController.php @@ -39,18 +39,24 @@ class GraphController public function graph(Request $request): Response { if ($this->allowedHosts !== [] && !in_array($request->getHost(), $this->allowedHosts, true)) { + $this->logger->warning('GraphController: rejected request from disallowed host', ['host' => $request->getHost()]); + return new Response('Forbidden', 403); } $theme = $request->query->get('theme', 'dark'); $cacheKey = 'graph_' . $theme; - $svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme): string { + $cacheMiss = false; + $svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme, &$cacheMiss): string { + $cacheMiss = true; $item->expiresAfter(3600); return $this->renderer->render($this->fetchAllContributions(), $theme); }); + $this->logger->debug('GraphController: cache ' . ($cacheMiss ? 'miss' : 'hit'), ['theme' => $theme]); + return new Response($svg, 200, [ 'Content-Type' => 'image/svg+xml', 'Cache-Control' => 'public, max-age=3600', @@ -86,7 +92,7 @@ class GraphController try { $contributions = $this->merge($contributions, $provider->fetch()); } catch (\Throwable $e) { - $this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage())); + $this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]); } } diff --git a/src/Service/GitHubProvider.php b/src/Service/GitHubProvider.php index 9c4a8f5..ea90ea3 100644 --- a/src/Service/GitHubProvider.php +++ b/src/Service/GitHubProvider.php @@ -4,6 +4,7 @@ namespace App\Service; use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClient; use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClientRegistryInterface; +use Psr\Log\LoggerInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -20,6 +21,7 @@ class GitHubProvider implements ProviderInterface private readonly GraphQLApiClientRegistryInterface $registry, private readonly string $username, private readonly string $token, + private readonly LoggerInterface $logger, ) {} public function isConfigured(): bool @@ -32,6 +34,8 @@ class GitHubProvider implements ProviderInterface */ public function fetch(): array { + $this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]); + $from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z'); $to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z'); @@ -79,6 +83,12 @@ class GitHubProvider implements ProviderInterface } } + $this->logger->info('GitHubProvider: fetched contributions', [ + 'user' => $this->username, + 'days' => count($result), + 'total' => array_sum($result), + ]); + return $result; } } diff --git a/src/Service/GitLabProvider.php b/src/Service/GitLabProvider.php index 2b5e6b6..802f046 100644 --- a/src/Service/GitLabProvider.php +++ b/src/Service/GitLabProvider.php @@ -2,6 +2,7 @@ namespace App\Service; +use Psr\Log\LoggerInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -16,6 +17,7 @@ class GitLabProvider implements ProviderInterface private readonly HttpClientInterface $client, private readonly string $username, private readonly string $token, + private readonly LoggerInterface $logger, private readonly string $baseUrl = '', ) {} @@ -31,6 +33,8 @@ class GitLabProvider implements ProviderInterface { $baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/'); + $this->logger->debug('GitLabProvider: fetching contributions', ['user' => $this->username, 'url' => $baseUrl]); + // Resolve numeric user ID from username $userResponse = $this->client->request('GET', "$baseUrl/api/v4/users", [ 'headers' => ['PRIVATE-TOKEN' => $this->token], @@ -67,6 +71,13 @@ class GitLabProvider implements ProviderInterface $page++; } while (count($events) === 100); + $this->logger->info('GitLabProvider: fetched contributions', [ + 'user' => $this->username, + 'pages' => $page - 1, + 'days' => count($result), + 'total' => array_sum($result), + ]); + return $result; } } diff --git a/src/Service/GiteaProvider.php b/src/Service/GiteaProvider.php index 118c5d7..c9f3c88 100644 --- a/src/Service/GiteaProvider.php +++ b/src/Service/GiteaProvider.php @@ -2,6 +2,7 @@ namespace App\Service; +use Psr\Log\LoggerInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -19,6 +20,7 @@ class GiteaProvider implements ProviderInterface private readonly string $username, private readonly string $token, private readonly string $baseUrl, + private readonly LoggerInterface $logger, ) {} public function isConfigured(): bool @@ -32,6 +34,9 @@ class GiteaProvider implements ProviderInterface public function fetch(): array { $baseUrl = rtrim($this->baseUrl, '/'); + + $this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $baseUrl]); + $response = $this->client->request('GET', "$baseUrl/api/v1/users/{$this->username}/heatmap", [ 'headers' => ['Authorization' => "token {$this->token}"], ]); @@ -48,6 +53,12 @@ class GiteaProvider implements ProviderInterface $result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions']; } + $this->logger->info('GiteaProvider: fetched contributions', [ + 'user' => $this->username, + 'days' => count($result), + 'total' => array_sum($result), + ]); + return $result; } }